diff --git a/packages/web/src/designer/ColumnLine.svelte b/packages/web/src/designer/ColumnLine.svelte new file mode 100644 index 000000000..c7854c428 --- /dev/null +++ b/packages/web/src/designer/ColumnLine.svelte @@ -0,0 +1,57 @@ + + +
+ x.designerId == designerId && x.columnName == column.columnName && x.isOutput + )} + onChange={e => { + if (e.target.checked) { + onChangeColumn( + { + ...column, + designerId, + }, + col => ({ ...col, isOutput: true }) + ); + } else { + onChangeColumn( + { + ...column, + designerId, + }, + col => ({ ...col, isOutput: false }) + ); + } + }} + /> + + {#if designerColumn?.filter} + + {/if} + {#if designerColumn?.sortOrder > 0} + + {/if} + {#if designerColumn?.sortOrder < 0} + + {/if} + {#if designerColumn?.isGrouped} + + {/if} +
diff --git a/packages/web/src/designer/Designer.svelte b/packages/web/src/designer/Designer.svelte new file mode 100644 index 000000000..539b07945 --- /dev/null +++ b/packages/web/src/designer/Designer.svelte @@ -0,0 +1,261 @@ + + + + +
+ {#if !(tables?.length > 0)} +
Drag & drop tables or views from left panel here
+ {/if} + +
+ + + {#each tables || [] as table (table.designerId)} + + {/each} +
+
+ + diff --git a/packages/web/src/designer/DesignerComponentCreator.ts b/packages/web/src/designer/DesignerComponentCreator.ts new file mode 100644 index 000000000..0f4ad809a --- /dev/null +++ b/packages/web/src/designer/DesignerComponentCreator.ts @@ -0,0 +1,91 @@ +import _ from 'lodash'; +import { dumpSqlSelect, Select, JoinType, Condition, Relation, mergeConditions, Source } from 'dbgate-sqltree'; +import { EngineDriver } from 'dbgate-types'; +import { DesignerInfo, DesignerTableInfo, DesignerReferenceInfo, DesignerJoinType } from './types'; +import { findPrimaryTable, findConnectingReference, referenceIsJoin, referenceIsExists } from './designerTools'; + +export class DesignerComponent { + subComponents: DesignerComponent[] = []; + parentComponent: DesignerComponent; + parentReference: DesignerReferenceInfo; + + tables: DesignerTableInfo[] = []; + nonPrimaryReferences: DesignerReferenceInfo[] = []; + + get primaryTable() { + return this.tables[0]; + } + get nonPrimaryTables() { + return this.tables.slice(1); + } + get nonPrimaryTablesAndReferences() { + return _.zip(this.nonPrimaryTables, this.nonPrimaryReferences); + } + get myAndParentTables() { + return [...this.parentTables, ...this.tables]; + } + get parentTables() { + return this.parentComponent ? this.parentComponent.myAndParentTables : []; + } + get thisAndSubComponentsTables() { + return [...this.tables, ..._.flatten(this.subComponents.map(x => x.thisAndSubComponentsTables))]; + } +} + +export class DesignerComponentCreator { + toAdd: DesignerTableInfo[]; + components: DesignerComponent[] = []; + + constructor(public designer: DesignerInfo) { + this.toAdd = [...designer.tables]; + while (this.toAdd.length > 0) { + const component = this.parseComponent(null); + this.components.push(component); + } + } + + parseComponent(root) { + if (root == null) { + root = findPrimaryTable(this.toAdd); + } + if (!root) return null; + _.remove(this.toAdd, x => x == root); + const res = new DesignerComponent(); + res.tables.push(root); + + for (;;) { + let found = false; + for (const test of this.toAdd) { + const ref = findConnectingReference(this.designer, res.tables, [test], referenceIsJoin); + if (ref) { + res.tables.push(test); + res.nonPrimaryReferences.push(ref); + _.remove(this.toAdd, x => x == test); + found = true; + break; + } + } + + if (!found) break; + } + + for (;;) { + let found = false; + for (const test of this.toAdd) { + const ref = findConnectingReference(this.designer, res.tables, [test], referenceIsExists); + if (ref) { + const subComponent = this.parseComponent(test); + res.subComponents.push(subComponent); + subComponent.parentComponent = res; + subComponent.parentReference = ref; + found = true; + break; + } + } + + if (!found) break; + } + + return res; + } +} diff --git a/packages/web/src/designer/DesignerQueryDumper.ts b/packages/web/src/designer/DesignerQueryDumper.ts new file mode 100644 index 000000000..d4c5e4af2 --- /dev/null +++ b/packages/web/src/designer/DesignerQueryDumper.ts @@ -0,0 +1,215 @@ +import _ from 'lodash'; +import { + dumpSqlSelect, + Select, + JoinType, + Condition, + Relation, + mergeConditions, + Source, + ResultField, +} from 'dbgate-sqltree'; +import { EngineDriver } from 'dbgate-types'; +import { DesignerInfo, DesignerTableInfo, DesignerReferenceInfo, DesignerJoinType } from './types'; +import { DesignerComponent } from './DesignerComponentCreator'; +import { + getReferenceConditions, + referenceIsCrossJoin, + referenceIsConnecting, + mergeSelectsFromDesigner, + findQuerySource, + findDesignerFilterType, +} from './designerTools'; +import { parseFilter } from 'dbgate-filterparser'; + +export class DesignerQueryDumper { + constructor(public designer: DesignerInfo, public components: DesignerComponent[]) {} + + get topLevelTables(): DesignerTableInfo[] { + return _.flatten(this.components.map(x => x.tables)); + } + + dumpComponent(component: DesignerComponent) { + const select: Select = { + commandType: 'select', + from: { + name: component.primaryTable, + alias: component.primaryTable.alias, + relations: [], + }, + }; + + for (const [table, ref] of component.nonPrimaryTablesAndReferences) { + select.from.relations.push({ + name: table, + alias: table.alias, + joinType: ref.joinType as JoinType, + conditions: getReferenceConditions(ref, this.designer), + }); + } + + for (const subComponent of component.subComponents) { + const subQuery = this.dumpComponent(subComponent); + subQuery.selectAll = true; + select.where = mergeConditions(select.where, { + conditionType: subComponent.parentReference.joinType == 'WHERE NOT EXISTS' ? 'notExists' : 'exists', + subQuery, + }); + } + + if (component.parentReference) { + select.where = mergeConditions(select.where, { + conditionType: 'and', + conditions: getReferenceConditions(component.parentReference, this.designer), + }); + + // cross join conditions in subcomponents + for (const ref of this.designer.references || []) { + if (referenceIsCrossJoin(ref) && referenceIsConnecting(ref, component.tables, component.myAndParentTables)) { + select.where = mergeConditions(select.where, { + conditionType: 'and', + conditions: getReferenceConditions(ref, this.designer), + }); + } + } + this.addConditions(select, component.tables); + } + + return select; + } + + addConditions(select: Select, tables: DesignerTableInfo[]) { + for (const column of this.designer.columns || []) { + if (!column.filter) continue; + const table = (this.designer.tables || []).find(x => x.designerId == column.designerId); + if (!table) continue; + if (!tables.find(x => x.designerId == table.designerId)) continue; + + const condition = parseFilter(column.filter, findDesignerFilterType(column, this.designer)); + if (condition) { + select.where = mergeConditions( + select.where, + _.cloneDeepWith(condition, expr => { + if (expr.exprType == 'placeholder') + return { + exprType: 'column', + columnName: column.columnName, + source: findQuerySource(this.designer, column.designerId), + }; + }) + ); + } + } + } + + addGroupConditions(select: Select, tables: DesignerTableInfo[], selectIsGrouped: boolean) { + for (const column of this.designer.columns || []) { + if (!column.groupFilter) continue; + const table = (this.designer.tables || []).find(x => x.designerId == column.designerId); + if (!table) continue; + if (!tables.find(x => x.designerId == table.designerId)) continue; + + const condition = parseFilter(column.groupFilter, findDesignerFilterType(column, this.designer)); + if (condition) { + select.having = mergeConditions( + select.having, + _.cloneDeepWith(condition, expr => { + if (expr.exprType == 'placeholder') { + return this.getColumnOutputExpression(column, selectIsGrouped); + } + }) + ); + } + } + } + + getColumnOutputExpression(col, selectIsGrouped): ResultField { + const source = findQuerySource(this.designer, col.designerId); + const { columnName } = col; + let { alias } = col; + if (selectIsGrouped && !col.isGrouped) { + // use aggregate + const aggregate = col.aggregate == null || col.aggregate == '---' ? 'MAX' : col.aggregate; + if (!alias) alias = `${aggregate}(${columnName})`; + + return { + exprType: 'call', + func: aggregate == 'COUNT DISTINCT' ? 'COUNT' : aggregate, + argsPrefix: aggregate == 'COUNT DISTINCT' ? 'DISTINCT' : null, + alias, + args: [ + { + exprType: 'column', + columnName, + source, + }, + ], + }; + } else { + return { + exprType: 'column', + columnName, + alias, + source, + }; + } + } + + run() { + let res: Select = null; + for (const component of this.components) { + const select = this.dumpComponent(component); + if (res == null) res = select; + else res = mergeSelectsFromDesigner(res, select); + } + + // top level cross join conditions + const topLevelTables = this.topLevelTables; + for (const ref of this.designer.references || []) { + if (referenceIsCrossJoin(ref) && referenceIsConnecting(ref, topLevelTables, topLevelTables)) { + res.where = mergeConditions(res.where, { + conditionType: 'and', + conditions: getReferenceConditions(ref, this.designer), + }); + } + } + + const topLevelColumns = (this.designer.columns || []).filter(col => + topLevelTables.find(tbl => tbl.designerId == col.designerId) + ); + const selectIsGrouped = !!topLevelColumns.find(x => x.isGrouped || (x.aggregate && x.aggregate != '---')); + const outputColumns = topLevelColumns.filter(x => x.isOutput); + if (outputColumns.length == 0) { + res.selectAll = true; + } else { + res.columns = outputColumns.map(col => this.getColumnOutputExpression(col, selectIsGrouped)); + } + + const groupedColumns = topLevelColumns.filter(x => x.isGrouped); + if (groupedColumns.length > 0) { + res.groupBy = groupedColumns.map(col => ({ + exprType: 'column', + columnName: col.columnName, + source: findQuerySource(this.designer, col.designerId), + })); + } + + const orderColumns = _.sortBy( + topLevelColumns.filter(x => x.sortOrder), + x => Math.abs(x.sortOrder) + ); + if (orderColumns.length > 0) { + res.orderBy = orderColumns.map(col => ({ + exprType: 'column', + direction: col.sortOrder < 0 ? 'DESC' : 'ASC', + columnName: col.columnName, + source: findQuerySource(this.designer, col.designerId), + })); + } + + this.addConditions(res, topLevelTables); + this.addGroupConditions(res, topLevelTables, selectIsGrouped); + + return res; + } +} diff --git a/packages/web/src/designer/DesignerTable.svelte b/packages/web/src/designer/DesignerTable.svelte new file mode 100644 index 000000000..727023c73 --- /dev/null +++ b/packages/web/src/designer/DesignerTable.svelte @@ -0,0 +1,86 @@ + + +
+
+
{alias || pureName}
+
onRemoveTable(table)}> + +
+
+
+ {#each columns || [] as column} + + {/each} +
+
+ + diff --git a/packages/web/src/designer/DomTableRef.ts b/packages/web/src/designer/DomTableRef.ts new file mode 100644 index 000000000..53aef90f0 --- /dev/null +++ b/packages/web/src/designer/DomTableRef.ts @@ -0,0 +1,39 @@ +import { DesignerTableInfo } from './types'; + +export default class DomTableRef { + domTable: Element; + domWrapper: Element; + table: DesignerTableInfo; + designerId: string; + domRefs: { [column: string]: Element }; + + constructor(table: DesignerTableInfo, domRefs, domWrapper: Element) { + this.domTable = domRefs['']; + this.domWrapper = domWrapper; + this.table = table; + this.designerId = table.designerId; + this.domRefs = domRefs; + } + + getRect() { + if (!this.domWrapper) return null; + if (!this.domTable) return null; + + const wrap = this.domWrapper.getBoundingClientRect(); + const rect = this.domTable.getBoundingClientRect(); + return { + left: rect.left - wrap.left, + top: rect.top - wrap.top, + right: rect.right - wrap.left, + bottom: rect.bottom - wrap.top, + }; + } + + getColumnY(columnName: string) { + let col = this.domRefs[columnName]; + if (!col) return null; + const rect = col.getBoundingClientRect(); + const wrap = this.domWrapper.getBoundingClientRect(); + return (rect.top + rect.bottom) / 2 - wrap.top; + } +} diff --git a/packages/web/src/designer/QueryDesigner.svelte b/packages/web/src/designer/QueryDesigner.svelte new file mode 100644 index 000000000..782816e47 --- /dev/null +++ b/packages/web/src/designer/QueryDesigner.svelte @@ -0,0 +1,5 @@ + + + diff --git a/packages/web/src/designer/cleanupDesignColumns.js b/packages/web/src/designer/cleanupDesignColumns.js new file mode 100644 index 000000000..171eefd50 --- /dev/null +++ b/packages/web/src/designer/cleanupDesignColumns.js @@ -0,0 +1,5 @@ +export default function cleanupDesignColumns(columns) { + return (columns || []).filter( + x => x.isOutput || x.isGrouped || x.alias || (x.aggregate && x.aggregate != '---') || x.sortOrder || x.filter + ); +} diff --git a/packages/web/src/designer/designerTools.ts b/packages/web/src/designer/designerTools.ts new file mode 100644 index 000000000..5ed0152d8 --- /dev/null +++ b/packages/web/src/designer/designerTools.ts @@ -0,0 +1,144 @@ +import _ from 'lodash'; +import { dumpSqlSelect, Select, JoinType, Condition, Relation, mergeConditions, Source } from 'dbgate-sqltree'; +import { EngineDriver } from 'dbgate-types'; +import { DesignerInfo, DesignerTableInfo, DesignerReferenceInfo, DesignerJoinType } from './types'; +import { DesignerComponentCreator } from './DesignerComponentCreator'; +import { DesignerQueryDumper } from './DesignerQueryDumper'; +import { getFilterType } from 'dbgate-filterparser'; + +export function referenceIsConnecting( + reference: DesignerReferenceInfo, + tables1: DesignerTableInfo[], + tables2: DesignerTableInfo[] +) { + return ( + (tables1.find(x => x.designerId == reference.sourceId) && tables2.find(x => x.designerId == reference.targetId)) || + (tables1.find(x => x.designerId == reference.targetId) && tables2.find(x => x.designerId == reference.sourceId)) + ); +} + +export function referenceIsJoin(reference) { + return ['INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'FULL OUTER JOIN'].includes(reference.joinType); +} +export function referenceIsExists(reference) { + return ['WHERE EXISTS', 'WHERE NOT EXISTS'].includes(reference.joinType); +} +export function referenceIsCrossJoin(reference) { + return !reference.joinType || reference.joinType == 'CROSS JOIN'; +} + +export function findConnectingReference( + designer: DesignerInfo, + tables1: DesignerTableInfo[], + tables2: DesignerTableInfo[], + additionalCondition: (ref: DesignerReferenceInfo) => boolean +) { + for (const ref of designer.references || []) { + if (additionalCondition(ref) && referenceIsConnecting(ref, tables1, tables2)) { + return ref; + } + } + return null; +} + +export function findQuerySource(designer: DesignerInfo, designerId: string): Source { + const table = designer.tables.find(x => x.designerId == designerId); + if (!table) return null; + return { + name: table, + alias: table.alias, + }; +} + +export function mergeSelectsFromDesigner(select1: Select, select2: Select): Select { + return { + commandType: 'select', + from: { + ...select1.from, + relations: [ + ...select1.from.relations, + { + joinType: 'CROSS JOIN', + name: select2.from.name, + alias: select2.from.alias, + }, + ...select2.from.relations, + ], + }, + where: mergeConditions(select1.where, select2.where), + }; +} + +export function findPrimaryTable(tables: DesignerTableInfo[]) { + return _.minBy(tables, x => x.top); +} + +export function getReferenceConditions(reference: DesignerReferenceInfo, designer: DesignerInfo): Condition[] { + const sourceTable = designer.tables.find(x => x.designerId == reference.sourceId); + const targetTable = designer.tables.find(x => x.designerId == reference.targetId); + + return reference.columns.map(col => ({ + conditionType: 'binary', + operator: '=', + left: { + exprType: 'column', + columnName: col.source, + source: { + name: sourceTable, + alias: sourceTable.alias, + }, + }, + right: { + exprType: 'column', + columnName: col.target, + source: { + name: targetTable, + alias: targetTable.alias, + }, + }, + })); +} + +export function generateDesignedQuery(designer: DesignerInfo, engine: EngineDriver) { + const { tables, columns, references } = designer; + const primaryTable = findPrimaryTable(designer.tables); + if (!primaryTable) return ''; + const componentCreator = new DesignerComponentCreator(designer); + const designerDumper = new DesignerQueryDumper(designer, componentCreator.components); + const select = designerDumper.run(); + + const dmp = engine.createDumper(); + dumpSqlSelect(dmp, select); + return dmp.s; +} + +export function isConnectedByReference( + designer: DesignerInfo, + table1: { designerId: string }, + table2: { designerId: string }, + withoutRef: { designerId: string } +) { + if (!designer.references) return false; + const creator = new DesignerComponentCreator({ + ...designer, + references: withoutRef + ? designer.references.filter(x => x.designerId != withoutRef.designerId) + : designer.references, + }); + const arrays = creator.components.map(x => x.thisAndSubComponentsTables); + const array1 = arrays.find(a => a.find(x => x.designerId == table1.designerId)); + const array2 = arrays.find(a => a.find(x => x.designerId == table2.designerId)); + return array1 == array2; +} + +export function findDesignerFilterType({ designerId, columnName }, designer) { + const table = (designer.tables || []).find(x => x.designerId == designerId); + if (table) { + const column = (table.columns || []).find(x => x.columnName == columnName); + if (column) { + const { dataType } = column; + return getFilterType(dataType); + } + } + return 'string'; +} diff --git a/packages/web/src/designer/types.ts b/packages/web/src/designer/types.ts new file mode 100644 index 000000000..c746f7ac7 --- /dev/null +++ b/packages/web/src/designer/types.ts @@ -0,0 +1,44 @@ +import { JoinType } from 'dbgate-sqltree'; +import { TableInfo } from 'dbgate-types'; + +export type DesignerTableInfo = TableInfo & { + designerId: string; + alias?: string; + left: number; + top: number; +}; + +export type DesignerJoinType = JoinType | 'WHERE EXISTS' | 'WHERE NOT EXISTS'; + +export type DesignerReferenceInfo = { + designerId: string; + joinType: DesignerJoinType; + sourceId: string; + targetId: string; + columns: { + source: string; + target: string; + }[]; +}; + +export type DesignerColumnInfo = { + designerId: string; + columnName: string; + alias?: string; + isGrouped?: boolean; + aggregate?: string; + isOutput?: boolean; + sortOrder?: number; + filter?: string; + groupFilter?: string; +}; + +export type DesignerInfo = { + tables: DesignerTableInfo[]; + columns: DesignerColumnInfo[]; + references: DesignerReferenceInfo[]; +}; + +// export type DesignerComponent = { +// tables: DesignerTableInfo[]; +// }; diff --git a/packages/web/src/plugins/ThemeDark.svelte b/packages/web/src/plugins/ThemeDark.svelte index 2e7ddbe6e..08404467f 100644 --- a/packages/web/src/plugins/ThemeDark.svelte +++ b/packages/web/src/plugins/ThemeDark.svelte @@ -32,6 +32,8 @@ --theme-bg-green: #1d3712; /* green-2 */ --theme-bg-volcano: #441d12; /* volcano-2 */ --theme-bg-red: #431418; /* red-2 */ + --theme-bg-blue: #15395b; /* blue-3 */ + --theme-bg-magenta: #551c3b; /* magenta-3 */ --theme-font-inv-1: #ffffff; --theme-font-inv-2: #b3b3b3; diff --git a/packages/web/src/plugins/ThemeLight.svelte b/packages/web/src/plugins/ThemeLight.svelte index 728f5285e..8edbf61fc 100644 --- a/packages/web/src/plugins/ThemeLight.svelte +++ b/packages/web/src/plugins/ThemeLight.svelte @@ -25,6 +25,8 @@ --theme-bg-green: #d9f7be; /* green-2 */ --theme-bg-volcano: #ffd8bf; /* volcano-2 */ --theme-bg-red: #ffccc7; /* red-2 */ + --theme-bg-blue: #91d5ff; /* blue-3 */ + --theme-bg-magenta: #ffadd2; /* magenta-3 */ --theme-font-inv-1: #ffffff; --theme-font-inv-2: #b3b3b3; diff --git a/packages/web/src/tabs/QueryDesignTab.svelte b/packages/web/src/tabs/QueryDesignTab.svelte new file mode 100644 index 000000000..7b311fb6e --- /dev/null +++ b/packages/web/src/tabs/QueryDesignTab.svelte @@ -0,0 +1,189 @@ + + + + + + diff --git a/packages/web/src/tabs/index.js b/packages/web/src/tabs/index.js index 46cc527b0..d272c5a62 100644 --- a/packages/web/src/tabs/index.js +++ b/packages/web/src/tabs/index.js @@ -12,7 +12,7 @@ import * as MarkdownEditorTab from './MarkdownEditorTab.svelte'; // import MarkdownViewTab from './MarkdownViewTab'; // import MarkdownPreviewTab from './MarkdownPreviewTab'; // import FavoriteEditorTab from './FavoriteEditorTab'; -// import QueryDesignTab from './QueryDesignTab'; +import * as QueryDesignTab from './QueryDesignTab.svelte'; export default { TableDataTab, @@ -29,5 +29,5 @@ export default { // MarkdownViewTab, // MarkdownPreviewTab, // FavoriteEditorTab, - // QueryDesignTab, + QueryDesignTab, };