Files
dbgate/packages/datalib/src/GridDisplay.ts
2024-10-10 13:59:41 +02:00

874 lines
24 KiB
TypeScript

import _ from 'lodash';
import { GridConfig, GridCache, GridConfigColumns, createGridCache, GroupFunc, createGridConfig } from './GridConfig';
import type {
ForeignKeyInfo,
TableInfo,
ColumnInfo,
EngineDriver,
NamedObjectInfo,
DatabaseInfo,
CollectionInfo,
SqlDialect,
ViewInfo,
FilterBehaviour,
} from 'dbgate-types';
import { parseFilter } from 'dbgate-filterparser';
import { filterName } from 'dbgate-tools';
import { ChangeSetFieldDefinition, ChangeSetRowDefinition } from './ChangeSet';
import { Expression, Select, treeToSql, dumpSqlSelect, Condition, CompoudCondition } from 'dbgate-sqltree';
import { isTypeLogical, standardFilterBehaviours, detectSqlFilterBehaviour, stringFilterBehaviour } from 'dbgate-tools';
export interface DisplayColumn {
schemaName: string;
pureName: string;
columnName: string;
headerText: string;
uniqueName: string;
uniquePath: string[];
notNull?: boolean;
autoIncrement?: boolean;
isPrimaryKey?: boolean;
// NoSQL specific
isPartitionKey?: boolean;
isClusterKey?: boolean;
isUniqueKey?: boolean;
foreignKey?: ForeignKeyInfo;
isForeignKeyUnique?: boolean;
isExpandable?: boolean;
isChecked?: boolean;
hintColumnNames?: string[];
dataType?: string;
filterBehaviour?: FilterBehaviour;
isStructured?: boolean;
}
export interface DisplayedColumnEx extends DisplayColumn {
sourceAlias: string;
}
export interface DisplayedColumnInfo {
[uniqueName: string]: DisplayedColumnEx;
}
// export type ReferenceActionResult = 'noAction' | 'loadRequired' | 'refAdded';
// export function combineReferenceActions(a: ReferenceActionResult, b: ReferenceActionResult): ReferenceActionResult {
// if (a == 'loadRequired' || b == 'loadRequired') return 'loadRequired';
// if (a == 'refAdded' || b == 'refAdded') return 'refAdded';
// return 'noAction';
// }
export type ChangeCacheFunc = (changeFunc: (cache: GridCache) => GridCache) => void;
export type ChangeConfigFunc = (changeFunc: (config: GridConfig) => GridConfig) => void;
export abstract class GridDisplay {
constructor(
public config: GridConfig,
protected setConfig: ChangeConfigFunc,
public cache: GridCache,
protected setCache: ChangeCacheFunc,
public driver?: EngineDriver,
public dbinfo: DatabaseInfo = null,
public serverVersion = null
) {
this.dialect = (driver?.dialectByVersion && driver?.dialectByVersion(serverVersion)) || driver?.dialect;
}
dialect: SqlDialect;
columns: DisplayColumn[];
formColumns: DisplayColumn[] = [];
baseTable?: TableInfo;
baseView?: ViewInfo;
baseCollection?: CollectionInfo;
get baseTableOrSimilar(): NamedObjectInfo {
return this.baseTable || this.baseCollection || this.baseView;
}
get baseTableOrCollection(): NamedObjectInfo {
return this.baseTable || this.baseCollection;
}
get baseTableOrView(): TableInfo | ViewInfo {
return this.baseTable || this.baseView;
}
changeSetKeyFields: string[] = null;
editableStructure: TableInfo = null;
sortable = false;
groupable = false;
filterable = false;
editable = false;
isLoadedCorrectly = true;
supportsReload = false;
isDynamicStructure = false;
filterBehaviourOverride = null;
setColumnVisibility(uniquePath: string[], isVisible: boolean) {
const uniqueName = uniquePath.join('.');
if (uniquePath.length == 1) {
this.includeInColumnSet([
{ field: 'hiddenColumns', uniqueName, isIncluded: !isVisible },
isVisible == false && this.isDynamicStructure && { field: 'addedColumns', uniqueName, isIncluded: false },
]);
} else {
this.includeInColumnSet([{ field: 'addedColumns', uniqueName, isIncluded: isVisible }]);
if (!this.isDynamicStructure) this.reload();
}
}
addDynamicColumn(name: string) {
this.includeInColumnSet([
{ field: 'addedColumns', uniqueName: name, isIncluded: true },
{ field: 'hiddenColumns', uniqueName: name, isIncluded: false },
]);
}
focusColumns(uniqueNames: string[]) {
this.setConfig(cfg => ({
...cfg,
focusedColumns: uniqueNames,
}));
}
get hasReferences() {
return false;
}
get focusedColumns() {
return this.config.focusedColumns;
}
get engine() {
return this.driver?.engine;
}
get allColumns() {
return this.getColumns(null).filter(col => col.isChecked || col.uniquePath.length == 1);
}
findColumn(uniqueName: string) {
return this.getColumns(null).find(x => x.uniqueName == uniqueName);
}
getFkTarget(column: DisplayColumn): TableInfo {
return null;
}
reload() {
this.setCache(reloadDataCacheFunc);
}
includeInColumnSet(
modifications: ({ field: keyof GridConfigColumns; uniqueName: string; isIncluded: boolean } | null)[]
) {
this.setConfig(cfg => {
let res = cfg;
for (const modification of modifications) {
if (!modification) {
continue;
}
const { field, uniqueName, isIncluded } = modification;
if (isIncluded) {
res = {
...res,
[field]: _.uniq([...(cfg[field] || []), uniqueName]),
};
} else {
res = {
...res,
[field]: _.uniq((cfg[field] || []).filter(x => x != uniqueName)),
};
}
}
return res;
});
}
showAllColumns() {
this.setConfig(cfg => ({
...cfg,
hiddenColumns: [],
}));
}
hideAllColumns() {
this.setConfig(cfg => ({
...cfg,
hiddenColumns: this.columns.filter(x => x.uniquePath.length == 1).map(x => x.uniqueName),
}));
}
get hiddenColumnIndexes() {
// console.log('GridDisplay.hiddenColumn', this.config.hiddenColumns);
return (this.config.hiddenColumns || []).map(x => _.findIndex(this.allColumns, y => y.uniqueName == x));
}
isColumnChecked(column: DisplayColumn) {
// console.log('isColumnChecked', column, this.config.hiddenColumns);
return column.uniquePath.length == 1
? !this.config.hiddenColumns.includes(column.uniqueName)
: this.config.addedColumns.includes(column.uniqueName);
}
applyFilterOnSelect(select: Select, displayedColumnInfo: DisplayedColumnInfo) {
const conditions = [];
for (const uniqueName in this.config.filters) {
const filter = this.config.filters[uniqueName];
if (!filter) continue;
const column = displayedColumnInfo[uniqueName];
if (!column) continue;
try {
const condition = parseFilter(
filter,
this.driver?.getFilterBehaviour(column.dataType, standardFilterBehaviours) ??
detectSqlFilterBehaviour(column.dataType)
);
if (condition) {
conditions.push(
_.cloneDeepWith(condition, (expr: Expression) => {
if (expr.exprType == 'placeholder') {
return this.createColumnExpression(column, { alias: column.sourceAlias }, undefined, 'filter');
}
// return {
// exprType: 'column',
// columnName: column.columnName,
// source: { alias: column.sourceAlias },
// };
})
);
}
} catch (err) {
console.warn(err.message);
continue;
}
}
if (this.baseTableOrView && this.config.multiColumnFilter) {
const orCondition: CompoudCondition = {
conditionType: 'or',
conditions: [],
};
for (const column of this.baseTableOrView.columns) {
try {
const condition = parseFilter(this.config.multiColumnFilter, detectSqlFilterBehaviour(column.dataType));
if (condition) {
orCondition.conditions.push(
_.cloneDeepWith(condition, (expr: Expression) => {
if (expr.exprType == 'placeholder') {
return this.createColumnExpression(column, { alias: 'basetbl' }, undefined, 'filter');
}
})
);
}
} catch (err) {
// skip for this column
continue;
}
}
if (orCondition.conditions.length > 0) {
conditions.push(orCondition);
}
}
if (conditions.length > 0) {
select.where = {
conditionType: 'and',
conditions,
};
}
}
applySortOnSelect(select: Select, displayedColumnInfo: DisplayedColumnInfo) {
if (this.config.sort?.length > 0) {
const orderByColumns = this.config.sort
.map(col => ({ ...col, dispInfo: displayedColumnInfo[col.uniqueName] }))
.map(col => ({ ...col, expr: select.columns.find(x => x.alias == col.uniqueName) }))
.filter(col => col.dispInfo && col.expr)
.map(col => ({
...col.expr,
direction: col.order,
}));
if (orderByColumns.length > 0) {
select.orderBy = orderByColumns;
}
}
}
get isGrouped() {
return !_.isEmpty(this.config.grouping);
}
get groupColumns() {
return this.isGrouped ? _.keys(_.pickBy(this.config.grouping, v => v == 'GROUP' || v.startsWith('GROUP:'))) : null;
}
applyGroupOnSelect(select: Select, displayedColumnInfo: DisplayedColumnInfo) {
const groupColumns = this.groupColumns;
if (groupColumns && groupColumns.length > 0) {
// @ts-ignore
select.groupBy = groupColumns.map(col => {
const colExpr: Expression = {
exprType: 'column',
columnName: displayedColumnInfo[col].columnName,
source: { alias: displayedColumnInfo[col].sourceAlias },
};
const grouping = this.config.grouping[col];
if (grouping.startsWith('GROUP:')) {
return {
exprType: 'transform',
transform: grouping,
expr: colExpr,
};
} else {
return colExpr;
}
});
}
if (!_.isEmpty(this.config.grouping)) {
for (let i = 0; i < select.columns.length; i++) {
const uniqueName = select.columns[i].alias;
// if (groupColumns && groupColumns.includes(uniqueName)) continue;
const grouping = this.getGrouping(uniqueName);
if (grouping == 'GROUP') {
continue;
} else if (grouping == 'NULL') {
select.columns[i].alias = null;
} else if (grouping && grouping.startsWith('GROUP:')) {
select.columns[i] = {
exprType: 'transform',
transform: grouping as any,
expr: select.columns[i],
alias: select.columns[i].alias,
};
} else {
let func = 'MAX';
let argsPrefix = '';
if (grouping) {
if (grouping == 'COUNT DISTINCT') {
func = 'COUNT';
argsPrefix = 'DISTINCT ';
} else {
func = grouping;
}
}
select.columns[i] = {
alias: select.columns[i].alias,
exprType: 'call',
func,
argsPrefix,
args: [select.columns[i]],
};
}
}
select.columns = select.columns.filter(x => x.alias);
}
}
getColumns(columnFilter) {
return this.columns.filter(col => filterName(columnFilter, col.columnName));
}
getGridColumns() {
return this.getColumns(null).filter(x => this.isColumnChecked(x));
}
isExpandedColumn(uniqueName: string) {
return this.config.expandedColumns.includes(uniqueName);
}
toggleExpandedColumn(uniqueName: string, value?: boolean) {
this.includeInColumnSet([
{ field: 'expandedColumns', uniqueName, isIncluded: value == null ? !this.isExpandedColumn(uniqueName) : value },
]);
}
getFilter(uniqueName: string) {
return this.config.filters[uniqueName];
}
setFilter(uniqueName, value) {
this.setConfig(cfg => ({
...cfg,
filters: {
...cfg.filters,
[uniqueName]: value,
},
formViewRecordNumber: 0,
}));
this.reload();
}
setMutliColumnFilter(value) {
this.setConfig(cfg => ({
...cfg,
multiColumnFilter: value,
formViewRecordNumber: 0,
}));
this.reload();
}
showFilter(uniqueName) {
this.setConfig(cfg => {
if (!cfg.filters.uniqueName)
return {
...cfg,
filters: {
..._.omitBy(cfg.filters, v => !v),
[uniqueName]: '',
},
};
return cfg;
});
}
removeFilter(uniqueName) {
this.setConfig(cfg => ({
...cfg,
filters: _.omit(cfg.filters, [uniqueName]),
formFilterColumns: (cfg.formFilterColumns || []).filter(x => x != uniqueName),
}));
this.reload();
}
setFilters(dct) {
this.setConfig(cfg => ({
...cfg,
filters: {
...cfg.filters,
...dct,
},
}));
this.reload();
}
setSort(uniqueName, order) {
this.setConfig(cfg => ({
...cfg,
sort: [{ uniqueName, order }],
}));
this.reload();
}
addToSort(uniqueName, order) {
this.setConfig(cfg => ({
...cfg,
sort: [...(cfg.sort || []), { uniqueName, order }],
}));
this.reload();
}
clearSort() {
this.setConfig(cfg => ({
...cfg,
sort: [],
}));
this.reload();
}
setGrouping(uniqueName, groupFunc: GroupFunc) {
this.setConfig(cfg => ({
...cfg,
grouping: groupFunc
? {
...cfg.grouping,
[uniqueName]: groupFunc,
}
: _.omitBy(cfg.grouping, (v, k) => k == uniqueName),
}));
this.reload();
}
getGrouping(uniqueName): GroupFunc {
if (this.isGrouped) {
if (this.config.grouping[uniqueName]) return this.config.grouping[uniqueName];
const column = (this.baseTable || this.baseView)?.columns?.find(x => x.columnName == uniqueName);
if (isTypeLogical(column?.dataType)) return 'COUNT DISTINCT';
if (column?.autoIncrement) return 'COUNT';
return 'MAX';
}
return null;
}
clearGrouping() {
this.setConfig(cfg => ({
...cfg,
grouping: {},
}));
this.reload();
}
getSortOrder(uniqueName) {
return this.config.sort.find(x => x.uniqueName == uniqueName)?.order;
}
getSortOrderIndex(uniqueName) {
if (this.config.sort.length <= 1) return -1;
return _.findIndex(this.config.sort, x => x.uniqueName == uniqueName);
}
isSortDefined() {
return (this.config.sort || []).length > 0;
}
get filterCount() {
return _.compact(_.values(this.config.filters)).length;
}
clearFilters() {
this.setConfig(cfg => ({
...cfg,
filters: {},
multiColumnFilter: null,
}));
this.reload();
}
resetConfig() {
this.setConfig(cfg => createGridConfig());
this.reload();
}
getChangeSetCondition(row) {
if (!this.changeSetKeyFields) return null;
return _.pick(row, this.changeSetKeyFields);
}
getChangeSetField(
row,
uniqueName,
insertedRowIndex,
existingRowIndex = null,
baseNameOmitable = false
): ChangeSetFieldDefinition {
const col = this.columns.find(x => x.uniqueName == uniqueName);
if (!col) return null;
const baseObj = this.baseTableOrSimilar;
if (!baseNameOmitable) {
if (!baseObj) return null;
if (baseObj.pureName != col.pureName || baseObj.schemaName != col.schemaName) {
return null;
}
}
return {
...this.getChangeSetRow(row, insertedRowIndex, existingRowIndex, baseNameOmitable),
uniqueName: uniqueName,
columnName: col.columnName,
};
}
getChangeSetRow(row, insertedRowIndex, existingRowIndex, baseNameOmitable = false): ChangeSetRowDefinition {
const baseObj = this.baseTableOrSimilar;
if (!baseNameOmitable && !baseObj) return null;
return {
pureName: baseObj?.pureName,
schemaName: baseObj?.schemaName,
insertedRowIndex,
existingRowIndex,
condition: insertedRowIndex == null && existingRowIndex == null ? this.getChangeSetCondition(row) : null,
};
}
createSelect(options = {}): Select {
return null;
}
processReferences(select: Select, displayedColumnInfo: DisplayedColumnInfo, options) {}
createColumnExpression(col, source, alias?, purpose: 'view' | 'filter' = 'view') {
let expr = null;
if (this.dialect.createColumnViewExpression) {
expr = this.dialect.createColumnViewExpression(col.columnName, col.dataType, source, alias, purpose);
if (expr) {
return expr;
}
}
return {
exprType: 'column',
alias: alias || col.columnName,
source,
...col,
};
}
createSelectBase(name: NamedObjectInfo, columns: ColumnInfo[], options, defaultOrderColumnName?: string) {
if (!columns) return null;
const orderColumnName = defaultOrderColumnName ?? columns[0]?.columnName;
const select: Select = {
commandType: 'select',
from: {
name: _.pick(name, ['schemaName', 'pureName']),
alias: 'basetbl',
},
columns: columns.map(col => this.createColumnExpression(col, { alias: 'basetbl' }, undefined, 'view')),
orderBy: this.driver?.requiresDefaultSortCriteria
? [
{
exprType: 'column',
columnName: orderColumnName,
direction: 'ASC',
},
]
: null,
};
const displayedColumnInfo = _.keyBy(
this.columns.map(col => ({ ...col, sourceAlias: 'basetbl' })),
'uniqueName'
);
this.processReferences(select, displayedColumnInfo, options);
this.applyFilterOnSelect(select, displayedColumnInfo);
this.applyGroupOnSelect(select, displayedColumnInfo);
this.applySortOnSelect(select, displayedColumnInfo);
return select;
}
getRowNumberOverSelect(select: Select, offset: number, count: number): Select {
const innerSelect: Select = {
commandType: 'select',
from: select.from,
where: select.where,
columns: [
...select.columns,
{
alias: '_RowNumber',
exprType: 'rowNumber',
orderBy: select.orderBy
? select.orderBy.map(x =>
x.exprType != 'column'
? x
: x.source
? x
: {
...x,
source: { alias: 'basetbl' },
}
)
: [
{
...select.columns[0],
direction: 'ASC',
},
],
},
],
};
const res: Select = {
commandType: 'select',
selectAll: true,
from: {
subQuery: innerSelect,
alias: '_RowNumberResult',
},
where: {
conditionType: 'between',
expr: {
exprType: 'column',
columnName: '_RowNumber',
source: {
alias: '_RowNumberResult',
},
},
left: {
exprType: 'value',
value: offset + 1,
},
right: {
exprType: 'value',
value: offset + count,
},
},
};
return res;
}
getPageQuery(offset: number, count: number) {
if (!this.driver) return null;
let select = this.createSelect();
if (!select) return null;
if (this.dialect.rangeSelect) select.range = { offset: offset, limit: count };
else if (this.dialect.rowNumberOverPaging && (offset > 0 || !this.dialect.topRecords))
select = this.getRowNumberOverSelect(select, offset, count);
else if (this.dialect.limitSelect) select.topRecords = count;
return select;
// const sql = treeToSql(this.driver, select, dumpSqlSelect);
// return sql;
}
getExportQuery(postprocessSelect = null) {
const select = this.createSelect({ isExport: true });
if (!select) return null;
if (postprocessSelect) postprocessSelect(select);
const sql = treeToSql(this.driver, select, dumpSqlSelect);
return sql;
}
getExportQueryJson(postprocessSelect = null) {
const select = this.createSelect({ isExport: true });
if (!select) return null;
if (postprocessSelect) postprocessSelect(select);
return select;
}
getExportColumnMap() {
const changesDefined = this.config.hiddenColumns?.length > 0 || this.config.addedColumns?.length > 0;
if (this.isDynamicStructure && !changesDefined) {
return null;
}
return this.getColumns(null)
.filter(col => col.isChecked)
.map(col => ({
dst: col.uniqueName,
src: col.uniqueName,
ignore: !changesDefined,
}));
}
resizeColumn(uniqueName: string, computedSize: number, diff: number) {
this.setConfig(cfg => {
const columnWidths = {
...cfg.columnWidths,
};
if (columnWidths[uniqueName]) {
columnWidths[uniqueName] += diff;
} else {
columnWidths[uniqueName] = computedSize + diff;
}
return {
...cfg,
columnWidths,
};
});
}
getCountQuery() {
let select = this.createSelect();
select.orderBy = null;
if (this.isGrouped) {
select = {
commandType: 'select',
from: {
subQuery: select,
alias: 'subq',
},
columns: [
{
exprType: 'raw',
sql: 'COUNT(*)',
alias: 'count',
},
],
};
} else {
select.columns = [
{
exprType: 'raw',
sql: 'COUNT(*)',
alias: 'count',
},
];
select.selectAll = false;
}
return select;
// const sql = treeToSql(this.driver, select, dumpSqlSelect);
// return sql;
}
compileJslFilters(): Condition {
const filters = this.config && this.config.filters;
if (!filters) return null;
const conditions = [];
for (const name in filters) {
const column = this.isDynamicStructure ? null : this.columns.find(x => x.columnName == name);
if (!this.isDynamicStructure && !column) continue;
const filterBehaviour =
this.filterBehaviourOverride ??
this.driver?.getFilterBehaviour(column.dataType, standardFilterBehaviours) ??
detectSqlFilterBehaviour(column.dataType);
try {
const condition = parseFilter(filters[name], filterBehaviour);
const replaced = _.cloneDeepWith(condition, (expr: Expression) => {
if (expr.exprType == 'placeholder')
return {
exprType: 'column',
columnName: this.isDynamicStructure ? name : column.columnName,
};
});
conditions.push(replaced);
} catch (err) {
// filter parse error - ignore filter
}
}
if (this.config.multiColumnFilter) {
const placeholderCondition = parseFilter(this.config.multiColumnFilter, stringFilterBehaviour);
if (placeholderCondition) {
conditions.push({
conditionType: 'anyColumnPass',
placeholderCondition,
});
}
}
if (conditions.length == 0) return null;
return {
conditionType: 'and',
conditions,
};
}
switchToFormView(rowIndex) {
this.setConfig(cfg => ({
...cfg,
isFormView: true,
formViewRecordNumber: rowIndex,
}));
}
switchToJsonView() {
this.setConfig(cfg => ({
...cfg,
isJsonView: true,
}));
}
formViewNavigate(command, allRowCount) {
switch (command) {
case 'begin':
this.setConfig(cfg => ({
...cfg,
formViewRecordNumber: 0,
}));
break;
case 'previous':
this.setConfig(cfg => ({
...cfg,
formViewRecordNumber: Math.max((cfg.formViewRecordNumber || 0) - 1, 0),
}));
break;
case 'next':
this.setConfig(cfg => ({
...cfg,
formViewRecordNumber: Math.max((cfg.formViewRecordNumber || 0) + 1, 0),
}));
break;
case 'end':
this.setConfig(cfg => ({
...cfg,
formViewRecordNumber: Math.max(allRowCount - 1, 0),
}));
break;
}
this.reload();
}
}
export function reloadDataCacheFunc(cache: GridCache): GridCache {
return {
// ...cache,
...createGridCache(),
refreshTime: new Date().getTime(),
};
}