@finos/legend-data-cube
Version:
529 lines (466 loc) • 17.1 kB
text/typescript
/**
* Copyright (c) 2020-present, Goldman Sachs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
guaranteeNonNullable,
isNonNullable,
uniq,
uniqBy,
} from '@finos/legend-shared';
import { DataCubeConfiguration } from '../../core/model/DataCubeConfiguration.js';
import {
type DataCubeSnapshot,
type DataCubeSnapshotSortColumn,
} from '../../core/DataCubeSnapshot.js';
import {
_findCol,
_toCol,
type DataCubeColumn,
} from '../../core/model/DataCubeColumn.js';
import { DataCubeSnapshotController } from '../../services/DataCubeSnapshotService.js';
import {
type DataCubeColumnPinPlacement,
DataCubeColumnKind,
DataCubeQueryFilterGroupOperator,
DataCubeQuerySortDirection,
isPivotResultColumnName,
getPivotResultColumnBaseColumnName,
isDimensionalGridMode,
} from '../../core/DataCubeQueryEngine.js';
import type {
DefaultMenuItem,
GetContextMenuItemsParams,
GetMainMenuItemsParams,
MenuItemDef,
} from 'ag-grid-community';
import type { DataCubeViewState } from '../DataCubeViewState.js';
import {
generateDimensionalMenuBuilder,
generateMenuBuilder,
} from './DataCubeGridMenuBuilder.js';
import {
buildFilterEditorTree,
buildFilterSnapshot,
DataCubeFilterEditorConditionGroupTreeNode,
type DataCubeFilterEditorConditionTreeNode,
type DataCubeFilterEditorTree,
type DataCubeFilterEditorTreeNode,
} from '../../core/filter/DataCubeQueryFilterEditorState.js';
import { _pruneExpandedPaths } from '../../core/DataCubeSnapshotBuilderUtils.js';
/**
* This query editor state is responsible for capturing updates to the data cube query
* caused by interactions with the grid which are either not captured by the server-side row model
* datasource, e.g. column pinning, column visibility changes, etc or done programatically via grid
* context menu. Think of this as a companion state for grid editor which bridges the gap between
* ag-grid state and data cube query state.
*
* More technically, this handles interactions that result in instant (not batched) change to the query.
* For example, in the editor, users can make changes to multiple parts of the query, but until they are
* explicit applied, these changes will not impact the query; whereas here a change immediately take effect.
*
* NOTE: since typically, each grid action causes a new snapshot to be created,
* we MUST NEVER use the editor here, as it could potentially create illegal state
* while the editor is still in the middle of a modification that has not been applied.
*/
export class DataCubeGridControllerState extends DataCubeSnapshotController {
readonly view: DataCubeViewState;
constructor(view: DataCubeViewState) {
super(view.engine, view.settingService, view.snapshotService);
this.view = view;
}
configuration = new DataCubeConfiguration();
menuBuilder?:
| ((
params:
| GetContextMenuItemsParams<unknown, { view: DataCubeViewState }>
| GetMainMenuItemsParams<unknown, { view: DataCubeViewState }>,
fromHeader: boolean,
) => (DefaultMenuItem | MenuItemDef)[])
| undefined;
getColumnConfiguration(colName: string | undefined) {
return _findCol(this.configuration.columns, colName);
}
// --------------------------------- FILTER ---------------------------------
filterTree: DataCubeFilterEditorTree = {
nodes: new Map<string, DataCubeFilterEditorTreeNode>(),
};
/**
* Add a new filter condition to the root of the filter tree.
* 1. If the root is empty, add a new AND group with the condition as the root
* 2. If the root is an AND group, add the condition to the root
* 3. If the root is an OR group, create a new AND group with the condition and
* wrapping the current root and set that as the new root
*/
addNewFilterCondition(condition: DataCubeFilterEditorConditionTreeNode) {
if (!this.filterTree.root) {
const root = new DataCubeFilterEditorConditionGroupTreeNode(
undefined,
DataCubeQueryFilterGroupOperator.AND,
undefined,
);
this.filterTree.nodes.set(root.uuid, root);
this.filterTree.root = root;
root.addChild(condition);
this.filterTree.nodes.set(condition.uuid, condition);
} else if (
this.filterTree.root.operation === DataCubeQueryFilterGroupOperator.AND
) {
this.filterTree.root.addChild(condition);
this.filterTree.nodes.set(condition.uuid, condition);
} else {
// Normally, for this case, we just wrap the current root with a new AND group
// but if the current (OR group) root has only 1 condition (this is only allowed
// if the group is root), we can just simply change the group operator to AND
const currentRoot = this.filterTree.root;
if (currentRoot.children.length === 1) {
currentRoot.operation = DataCubeQueryFilterGroupOperator.AND;
currentRoot.addChild(condition);
this.filterTree.nodes.set(condition.uuid, condition);
} else {
const newRoot = new DataCubeFilterEditorConditionGroupTreeNode(
undefined,
DataCubeQueryFilterGroupOperator.AND,
undefined,
);
this.filterTree.nodes.set(newRoot.uuid, newRoot);
this.filterTree.root = newRoot;
newRoot.addChild(currentRoot);
newRoot.addChild(condition);
this.filterTree.nodes.set(condition.uuid, condition);
}
}
this.applyChanges();
}
clearFilters() {
this.filterTree.root = undefined;
this.filterTree.nodes = new Map<string, DataCubeFilterEditorTreeNode>();
this.applyChanges();
}
// --------------------------------- COLUMNS ---------------------------------
selectColumns: DataCubeColumn[] = [];
leafExtendedColumns: DataCubeColumn[] = [];
groupExtendedColumns: DataCubeColumn[] = [];
pinColumn(
colName: string | undefined,
placement: DataCubeColumnPinPlacement | undefined,
) {
const columnConfiguration = this.getColumnConfiguration(colName);
if (columnConfiguration) {
columnConfiguration.pinned = placement;
this.applyChanges();
}
}
rearrangeColumns(columns: string[]) {
const rearrangedColumnConfigurations = columns
.map((colName) => this.getColumnConfiguration(colName))
.filter(isNonNullable);
this.configuration.columns = [
...rearrangedColumnConfigurations,
...this.configuration.columns.filter(
(col) => !rearrangedColumnConfigurations.includes(col),
),
];
const rearrangedSelectColumns = columns
.map((colName) => _findCol(this.selectColumns, colName))
.filter(isNonNullable);
this.selectColumns = [
...rearrangedSelectColumns,
...rearrangedSelectColumns.filter(
(col) => !rearrangedSelectColumns.includes(col),
),
];
this.applyChanges();
}
removeAllPins() {
this.configuration.columns.forEach((col) => (col.pinned = undefined));
this.applyChanges();
}
showColumn(colName: string | undefined, isVisible: boolean) {
const columnConfiguration = this.getColumnConfiguration(colName);
if (columnConfiguration) {
columnConfiguration.hideFromView = !isVisible;
this.applyChanges();
}
}
// --------------------------------- PIVOT ---------------------------------
horizontalPivotColumns: DataCubeColumn[] = [];
horizontalPivotCastColumns: DataCubeColumn[] = [];
private get horizontalPivotResultColumns() {
return this.horizontalPivotCastColumns
.filter((col) => isPivotResultColumnName(col.name))
.map(_toCol);
}
getHorizontalPivotableColumn(colName: string) {
return _findCol(
this.configuration.columns.filter(
(col) =>
col.kind === DataCubeColumnKind.DIMENSION &&
// exclude group-level extended columns
!_findCol(this.groupExtendedColumns, col.name),
),
colName,
);
}
setHorizontalPivotOnColumn(colName: string) {
const column = this.getHorizontalPivotableColumn(colName);
if (column) {
this.horizontalPivotColumns = [column];
this.applyChanges();
}
}
addHorizontalPivotOnColumn(colName: string) {
const column = this.getHorizontalPivotableColumn(colName);
if (column) {
this.horizontalPivotColumns = [...this.horizontalPivotColumns, column];
this.applyChanges();
}
}
clearAllHorizontalPivots() {
this.horizontalPivotColumns = [];
this.horizontalPivotCastColumns = [];
this.applyChanges();
}
excludeColumnFromHorizontalPivot(colName: string) {
if (isPivotResultColumnName(colName)) {
const baseColumnName = getPivotResultColumnBaseColumnName(colName);
const columnConfiguration = this.getColumnConfiguration(baseColumnName);
if (columnConfiguration && !columnConfiguration.excludedFromPivot) {
columnConfiguration.excludedFromPivot = true;
this.applyChanges();
}
}
}
includeColumnInHorizontalPivot(colName: string) {
const columnConfiguration = this.getColumnConfiguration(colName);
if (columnConfiguration?.excludedFromPivot) {
columnConfiguration.excludedFromPivot = false;
this.applyChanges();
}
}
// --------------------------------- GROUP BY ---------------------------------
verticalPivotColumns: DataCubeColumn[] = [];
getVerticalPivotableColumn(colName: string) {
return _findCol(
this.configuration.columns.filter(
(col) =>
col.kind === DataCubeColumnKind.DIMENSION &&
// exclude group-level extended columns
!_findCol(this.groupExtendedColumns, col.name) &&
// exclude pivot columns
!_findCol(this.horizontalPivotColumns, col.name),
),
colName,
);
}
setVerticalPivotOnColumn(colName: string) {
const column = this.getVerticalPivotableColumn(colName);
if (column) {
this.verticalPivotColumns = [column];
this.applyChanges();
}
}
addVerticalPivotOnColumn(colName: string) {
const column = this.getVerticalPivotableColumn(colName);
if (column) {
this.verticalPivotColumns = [...this.verticalPivotColumns, column];
this.applyChanges();
}
}
removeVerticalPivotOnColumn(colName: string) {
this.verticalPivotColumns = this.verticalPivotColumns.filter(
(col) => col.name !== colName,
);
this.applyChanges();
}
clearAllVerticalPivots() {
this.verticalPivotColumns = [];
this.applyChanges();
}
collapseAllPaths() {
this.view.grid.client.collapseAll();
this.configuration.pivotLayout.expandedPaths = [];
this.applyChanges();
}
expandPath(path: string) {
this.configuration.pivotLayout.expandedPaths = uniq([
...this.configuration.pivotLayout.expandedPaths,
path,
]).sort();
this.applyChanges();
}
collapsePath(path: string) {
this.configuration.pivotLayout.expandedPaths =
this.configuration.pivotLayout.expandedPaths.filter((p) => p !== path);
this.applyChanges();
}
// --------------------------------- SORT ---------------------------------
sortColumns: DataCubeSnapshotSortColumn[] = [];
getSortableColumn(colName: string | undefined) {
if (!colName) {
return undefined;
}
return _findCol(
[
...(this.horizontalPivotCastColumns.length
? this.horizontalPivotCastColumns
: this.selectColumns),
...this.groupExtendedColumns,
],
colName,
);
}
setSortByColumn(colName: string, direction: DataCubeQuerySortDirection) {
const column = this.getSortableColumn(colName);
if (!column) {
return;
}
this.sortColumns = [
{
...column,
direction,
},
];
this.applyChanges();
}
addSortByColumn(colName: string, direction: DataCubeQuerySortDirection) {
const column = this.getSortableColumn(colName);
if (!column) {
return;
}
this.sortColumns = [...this.sortColumns, { ...column, direction }];
this.applyChanges();
}
clearSortByColumn(colName: string) {
this.sortColumns = this.sortColumns.filter((col) => col.name !== colName);
this.applyChanges();
}
clearAllSorts() {
this.sortColumns = [];
this.applyChanges();
}
// --------------------------------- MAIN ---------------------------------
override getSnapshotSubscriberName() {
return 'grid-controller';
}
override async applySnapshot(
snapshot: DataCubeSnapshot,
previousSnapshot: DataCubeSnapshot | undefined,
) {
this.configuration = DataCubeConfiguration.serialization.fromJson(
snapshot.data.configuration,
);
this.filterTree.nodes = new Map<string, DataCubeFilterEditorTreeNode>();
this.filterTree.root = snapshot.data.filter
? buildFilterEditorTree(
snapshot.data.filter,
undefined,
this.filterTree.nodes,
(operator) => this.view.engine.getFilterOperation(operator),
)
: undefined;
this.selectColumns = snapshot.data.selectColumns;
this.leafExtendedColumns = snapshot.data.leafExtendedColumns;
this.groupExtendedColumns = snapshot.data.groupExtendedColumns;
this.horizontalPivotColumns = snapshot.data.pivot?.columns ?? [];
this.horizontalPivotCastColumns = snapshot.data.pivot?.castColumns ?? [];
this.verticalPivotColumns = snapshot.data.groupBy?.columns ?? [];
this.sortColumns = snapshot.data.sortColumns;
this.menuBuilder = isDimensionalGridMode(
this.view.info.configuration.gridMode,
)
? generateDimensionalMenuBuilder(this)
: generateMenuBuilder(this);
}
private propagateChanges(baseSnapshot: DataCubeSnapshot) {
this.verticalPivotColumns = this.verticalPivotColumns.filter(
(col) => !_findCol(this.horizontalPivotColumns, col.name),
);
this.configuration.pivotLayout.expandedPaths = _pruneExpandedPaths(
baseSnapshot.data.groupBy?.columns ?? [],
this.verticalPivotColumns,
this.configuration.pivotLayout.expandedPaths,
);
this.configuration.columns.forEach((col) => {
col.pivotSortDirection = _findCol(this.horizontalPivotColumns, col.name)
? (col.pivotSortDirection ?? DataCubeQuerySortDirection.ASCENDING)
: undefined;
});
this.selectColumns = uniqBy(
[
...this.configuration.columns.filter(
(col) =>
col.isSelected && !_findCol(this.groupExtendedColumns, col.name),
),
...this.horizontalPivotColumns,
...this.verticalPivotColumns,
],
(col) => col.name,
).map(_toCol);
const sortableColumns = uniqBy(
[
// if pivot is active, take the pivot result columns and include
// selected dimension columns which are not part of pivot columns
...(this.horizontalPivotColumns.length
? [
...this.horizontalPivotResultColumns,
...[
...this.configuration.columns.filter((col) => col.isSelected),
...this.verticalPivotColumns,
].filter(
(column) =>
this.getColumnConfiguration(column.name)?.kind ===
DataCubeColumnKind.DIMENSION &&
!_findCol(this.horizontalPivotColumns, column.name),
),
]
: [
...this.configuration.columns.filter((col) => col.isSelected),
...this.verticalPivotColumns,
]),
...this.groupExtendedColumns,
],
(col) => col.name,
);
this.sortColumns = this.sortColumns.filter((col) =>
_findCol(sortableColumns, col.name),
);
}
private applyChanges() {
const baseSnapshot = guaranteeNonNullable(this.getLatestSnapshot());
const snapshot = baseSnapshot.clone();
this.propagateChanges(baseSnapshot);
snapshot.data.configuration = this.configuration.serialize();
snapshot.data.filter = this.filterTree.root
? buildFilterSnapshot(this.filterTree.root)
: undefined;
snapshot.data.selectColumns = this.selectColumns;
snapshot.data.pivot = this.horizontalPivotColumns.length
? {
columns: this.horizontalPivotColumns,
castColumns: this.horizontalPivotCastColumns,
}
: undefined;
snapshot.data.groupBy = this.verticalPivotColumns.length
? {
columns: this.verticalPivotColumns,
}
: undefined;
snapshot.data.sortColumns = this.sortColumns;
snapshot.finalize();
if (snapshot.hashCode !== baseSnapshot.hashCode) {
this.publishSnapshot(snapshot);
}
}
}