@finos/legend-application-studio 28.20.0 → 28.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/__lib__/LegendStudioUserDataHelper.d.ts +4 -1
- package/lib/__lib__/LegendStudioUserDataHelper.d.ts.map +1 -1
- package/lib/__lib__/LegendStudioUserDataHelper.js +18 -0
- package/lib/__lib__/LegendStudioUserDataHelper.js.map +1 -1
- package/lib/components/editor/editor-group/EditorGroup.d.ts.map +1 -1
- package/lib/components/editor/editor-group/EditorGroup.js +5 -0
- package/lib/components/editor/editor-group/EditorGroup.js.map +1 -1
- package/lib/components/editor/editor-group/dataProduct/DataProductEditor.d.ts.map +1 -1
- package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js +13 -44
- package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js.map +1 -1
- package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.d.ts +26 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.d.ts.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.js +101 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.js.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.d.ts +23 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.d.ts.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.js +434 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.js.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.d.ts +242 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.d.ts.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.js +371 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.js.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseEditor.d.ts +29 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseEditor.d.ts.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseEditor.js +78 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseEditor.js.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.d.ts +30 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.d.ts.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.js +331 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.js.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.d.ts +104 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.d.ts.map +1 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.js +151 -0
- package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.js.map +1 -0
- package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.d.ts.map +1 -1
- package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.js +3 -78
- package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.js.map +1 -1
- package/lib/index.css +2 -2
- package/lib/index.css.map +1 -1
- package/lib/package.json +4 -1
- package/lib/stores/editor/EditorTabManagerState.d.ts.map +1 -1
- package/lib/stores/editor/EditorTabManagerState.js +5 -3
- package/lib/stores/editor/EditorTabManagerState.js.map +1 -1
- package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.d.ts +252 -0
- package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.d.ts.map +1 -0
- package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.js +755 -0
- package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.js.map +1 -0
- package/package.json +12 -9
- package/src/__lib__/LegendStudioUserDataHelper.ts +30 -0
- package/src/components/editor/editor-group/EditorGroup.tsx +4 -0
- package/src/components/editor/editor-group/dataProduct/DataProductEditor.tsx +0 -52
- package/src/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.tsx +200 -0
- package/src/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.tsx +701 -0
- package/src/components/editor/editor-group/database-editor/DatabaseDiagramHelper.ts +555 -0
- package/src/components/editor/editor-group/database-editor/DatabaseEditor.tsx +246 -0
- package/src/components/editor/editor-group/database-editor/DatabaseSchemaTree.tsx +1053 -0
- package/src/components/editor/editor-group/database-editor/DatabaseTableNode.tsx +465 -0
- package/src/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.tsx +2 -242
- package/src/stores/editor/EditorTabManagerState.ts +4 -5
- package/src/stores/editor/editor-state/element-editor-state/DatabaseEditorState.ts +938 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-present, Goldman Sachs
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import dagre from '@dagrejs/dagre';
|
|
18
|
+
import { filterByType } from '@finos/legend-shared';
|
|
19
|
+
import {
|
|
20
|
+
BusinessMilestoning,
|
|
21
|
+
BusinessSnapshotMilestoning,
|
|
22
|
+
Column,
|
|
23
|
+
type Database,
|
|
24
|
+
type Join,
|
|
25
|
+
type Milestoning,
|
|
26
|
+
ProcessingMilestoning,
|
|
27
|
+
ProcessingSnapshotMilestoning,
|
|
28
|
+
type Schema,
|
|
29
|
+
type Table,
|
|
30
|
+
View,
|
|
31
|
+
type RelationalOperationElement,
|
|
32
|
+
stringifyDataType,
|
|
33
|
+
} from '@finos/legend-graph';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Tables and Views are the two top-level "relations" a Database schema can
|
|
37
|
+
* contain. They share a common metamodel ancestor (NamedRelation) and almost
|
|
38
|
+
* the same on-canvas treatment, so most helpers and the React node component
|
|
39
|
+
* accept either.
|
|
40
|
+
*/
|
|
41
|
+
export type DatabaseRelation = Table | View;
|
|
42
|
+
|
|
43
|
+
export const isView = (relation: DatabaseRelation): relation is View =>
|
|
44
|
+
relation instanceof View;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Stable identifier for a relation within a Database, encoded as
|
|
48
|
+
* `<schema>.<name>`. Schema-qualified to disambiguate same-named relations
|
|
49
|
+
* across schemas. Works for both Tables and Views.
|
|
50
|
+
*/
|
|
51
|
+
export const getRelationId = (relation: DatabaseRelation): string =>
|
|
52
|
+
`${relation.schema.name}.${relation.name}`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Display string for a relational data type (e.g. `VARCHAR(40)`,
|
|
56
|
+
* `DECIMAL(10,4)`). Wraps the upstream helper so the editor doesn't need to
|
|
57
|
+
* know about RelationalDataType subclasses directly.
|
|
58
|
+
*/
|
|
59
|
+
export const getColumnTypeLabel = (column: Column): string =>
|
|
60
|
+
stringifyDataType(column.type);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `Relation.columns` is statically typed as `RelationalOperationElement[]`
|
|
64
|
+
* (the broader supertype) but is populated with `Column` instances at runtime
|
|
65
|
+
* for tables. Filter so the form-mode editor can rely on the narrower type
|
|
66
|
+
* without casting at every call site.
|
|
67
|
+
*/
|
|
68
|
+
export const getTableColumns = (table: Table): Column[] =>
|
|
69
|
+
table.columns.filter(filterByType(Column));
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a column participates in a relation's primary key. Both Tables and
|
|
73
|
+
* Views can declare a primary key (`primaryKey: Column[]`). For Views we
|
|
74
|
+
* compare by name because a view's `columnMappings` carry their column name
|
|
75
|
+
* directly rather than referencing the same `Column` instances.
|
|
76
|
+
*/
|
|
77
|
+
export const isPrimaryKey = (
|
|
78
|
+
relation: DatabaseRelation,
|
|
79
|
+
columnName: string,
|
|
80
|
+
): boolean => relation.primaryKey.some((pk) => pk.name === columnName);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Placeholder text shown while view-column Pure-code formulas are still
|
|
84
|
+
* loading (or if rendering them failed). Centralized so both the canvas
|
|
85
|
+
* table node and the tree column row stay in sync.
|
|
86
|
+
*/
|
|
87
|
+
export const VIEW_COLUMN_FORMULA_PLACEHOLDER = 'calculate [...]';
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the Pure-code formula for a single view column mapping. Looks the
|
|
91
|
+
* pre-rendered formula up by `<schema>.<view>.<column>` key in the map
|
|
92
|
+
* populated by `DatabaseEditorState.loadViewColumnFormulas()`. Falls back to
|
|
93
|
+
* a static placeholder when the formula isn't ready yet (initial load,
|
|
94
|
+
* background re-render) or the engine couldn't render it.
|
|
95
|
+
*
|
|
96
|
+
* The map is passed in (not read directly here) so this module stays
|
|
97
|
+
* framework-agnostic — DatabaseDiagramHelper has no MobX dependency.
|
|
98
|
+
*/
|
|
99
|
+
export const resolveViewColumnFormula = (
|
|
100
|
+
formulas: ReadonlyMap<string, string>,
|
|
101
|
+
schemaName: string,
|
|
102
|
+
viewName: string,
|
|
103
|
+
columnName: string,
|
|
104
|
+
): string =>
|
|
105
|
+
formulas.get(`${schemaName}.${viewName}.${columnName}`) ??
|
|
106
|
+
VIEW_COLUMN_FORMULA_PLACEHOLDER;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Placeholder text shown while filter Pure-code formulas are still loading
|
|
110
|
+
* (or if rendering them failed). Centralized so the side-panel filter row
|
|
111
|
+
* has a sensible fallback during the brief async window.
|
|
112
|
+
*/
|
|
113
|
+
export const FILTER_FORMULA_PLACEHOLDER = 'filter [...]';
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the Pure-code formula for a single database-level filter. Filter
|
|
117
|
+
* names are unique within a Database, so the lookup key is just the filter
|
|
118
|
+
* name (no schema qualifier needed). Falls back to a static placeholder when
|
|
119
|
+
* the formula isn't ready yet or the engine couldn't render it.
|
|
120
|
+
*/
|
|
121
|
+
export const resolveFilterFormula = (
|
|
122
|
+
formulas: ReadonlyMap<string, string>,
|
|
123
|
+
filterName: string,
|
|
124
|
+
): string => formulas.get(filterName) ?? FILTER_FORMULA_PLACEHOLDER;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Placeholder shown while a join's Pure-code operation is loading or if
|
|
128
|
+
* rendering failed. Same async-load pattern as filters and view-column
|
|
129
|
+
* formulas — reused so the side panel and canvas tooltips stay in sync.
|
|
130
|
+
*/
|
|
131
|
+
export const JOIN_FORMULA_PLACEHOLDER = 'join [...]';
|
|
132
|
+
|
|
133
|
+
export const resolveJoinFormula = (
|
|
134
|
+
formulas: ReadonlyMap<string, string>,
|
|
135
|
+
joinName: string,
|
|
136
|
+
): string => formulas.get(joinName) ?? JOIN_FORMULA_PLACEHOLDER;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Placeholder shown for view groupBy expressions while the engine render
|
|
140
|
+
* is in flight or if rendering failed. Kept distinct from the column-mapping
|
|
141
|
+
* placeholder so users can tell at a glance which kind of expression is
|
|
142
|
+
* still loading.
|
|
143
|
+
*/
|
|
144
|
+
export const VIEW_GROUP_BY_FORMULA_PLACEHOLDER = 'group by [...]';
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the Pure-code formula for a single view groupBy column expression.
|
|
148
|
+
* Keys mirror the loader in `DatabaseEditorState`:
|
|
149
|
+
* `<schema>.<view>.groupBy[<index>]`. Falls back to the static placeholder
|
|
150
|
+
* when the formula isn't ready yet.
|
|
151
|
+
*/
|
|
152
|
+
export const resolveViewGroupByFormula = (
|
|
153
|
+
formulas: ReadonlyMap<string, string>,
|
|
154
|
+
schemaName: string,
|
|
155
|
+
viewName: string,
|
|
156
|
+
index: number,
|
|
157
|
+
): string =>
|
|
158
|
+
formulas.get(`${schemaName}.${viewName}.groupBy[${index}]`) ??
|
|
159
|
+
VIEW_GROUP_BY_FORMULA_PLACEHOLDER;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Lowercase, trimmed search-text matcher used by the side-panel tree.
|
|
163
|
+
* Empty query matches everything (so consumers don't need a special case).
|
|
164
|
+
* Match is case-insensitive substring \u2014 not fuzzy \u2014 because users
|
|
165
|
+
* typically know the exact prefix of the schema/relation/column they're
|
|
166
|
+
* looking for and substring keeps the "what matched" obvious.
|
|
167
|
+
*/
|
|
168
|
+
export const matchesSearch = (name: string, query: string): boolean => {
|
|
169
|
+
if (!query) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
return name.toLowerCase().includes(query);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Renders a single `Milestoning` instance into a short, grammar-flavored
|
|
177
|
+
* label and a longer human description. Mirrors the four concrete subclasses
|
|
178
|
+
* the metamodel ships today:
|
|
179
|
+
*
|
|
180
|
+
* - `BusinessMilestoning` — `business[from..thru]` ("thru inclusive" tag)
|
|
181
|
+
* - `BusinessSnapshotMilestoning` — `business snapshot(<col>)`
|
|
182
|
+
* - `ProcessingMilestoning` — `processing[in..out]` ("out inclusive" tag)
|
|
183
|
+
* - `ProcessingSnapshotMilestoning` — `processing snapshot(<col>)`
|
|
184
|
+
*
|
|
185
|
+
* Unknown subclasses (extension milestonings introduced via plugins) fall
|
|
186
|
+
* back to the constructor name so the user at least sees "something is
|
|
187
|
+
* configured here" instead of a silent omission.
|
|
188
|
+
*/
|
|
189
|
+
export interface MilestoningSummary {
|
|
190
|
+
/** Short grammar-style label, e.g. `business[from..thru]`. */
|
|
191
|
+
label: string;
|
|
192
|
+
/** Longer human-readable description used as the tooltip. */
|
|
193
|
+
description: string;
|
|
194
|
+
/** Stable kind tag for styling: `business` | `processing` | `unknown`. */
|
|
195
|
+
kind: 'business' | 'processing' | 'unknown';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const summarizeMilestoning = (
|
|
199
|
+
milestoning: Milestoning,
|
|
200
|
+
): MilestoningSummary => {
|
|
201
|
+
if (milestoning instanceof BusinessMilestoning) {
|
|
202
|
+
const inclusive = milestoning.thruIsInclusive ? ', thru inclusive' : '';
|
|
203
|
+
return {
|
|
204
|
+
label: `business[${milestoning.from}\u2026${milestoning.thru}]`,
|
|
205
|
+
description: `Business milestoning on columns ${milestoning.from} \u2192 ${milestoning.thru}${inclusive}`,
|
|
206
|
+
kind: 'business',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (milestoning instanceof BusinessSnapshotMilestoning) {
|
|
210
|
+
return {
|
|
211
|
+
label: `business snapshot(${milestoning.snapshotDate})`,
|
|
212
|
+
description: `Business snapshot milestoning on column ${milestoning.snapshotDate}`,
|
|
213
|
+
kind: 'business',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (milestoning instanceof ProcessingMilestoning) {
|
|
217
|
+
const inclusive = milestoning.outIsInclusive ? ', out inclusive' : '';
|
|
218
|
+
return {
|
|
219
|
+
label: `processing[${milestoning.in}\u2026${milestoning.out}]`,
|
|
220
|
+
description: `Processing milestoning on columns ${milestoning.in} \u2192 ${milestoning.out}${inclusive}`,
|
|
221
|
+
kind: 'processing',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
if (milestoning instanceof ProcessingSnapshotMilestoning) {
|
|
225
|
+
return {
|
|
226
|
+
label: `processing snapshot(${milestoning.snapshotDate})`,
|
|
227
|
+
description: `Processing snapshot milestoning on column ${milestoning.snapshotDate}`,
|
|
228
|
+
kind: 'processing',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
// Plugin-defined milestoning kinds: surface the class name so the user
|
|
232
|
+
// at least knows the table is milestoned even if we can't decode it.
|
|
233
|
+
const className = milestoning.constructor.name;
|
|
234
|
+
return {
|
|
235
|
+
label: className,
|
|
236
|
+
description: `Custom milestoning (${className})`,
|
|
237
|
+
kind: 'unknown',
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Whether a join's two endpoints are the same relation (e.g. a hierarchy
|
|
243
|
+
* self-join: `Employee → Employee` on `managerId = id`). We treat any join
|
|
244
|
+
* whose first alias pair has identical source/target relation ids as a
|
|
245
|
+
* self-join — mirrors how `buildJoinEdges` matches endpoints.
|
|
246
|
+
*/
|
|
247
|
+
export const isSelfJoin = (join: Join): boolean => {
|
|
248
|
+
const firstPair = join.aliases[0];
|
|
249
|
+
if (!firstPair) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
const source = firstPair.first.relation.value;
|
|
253
|
+
const target = firstPair.second.relation.value;
|
|
254
|
+
return (
|
|
255
|
+
source.schema.name === target.schema.name && source.name === target.name
|
|
256
|
+
);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Whether either endpoint of a join lives outside `database` (i.e. resolves
|
|
261
|
+
* to a relation owned by another, included, store). Used to surface a
|
|
262
|
+
* "CROSS-DB" badge in the side panel and to render the canvas placeholder
|
|
263
|
+
* node for the foreign endpoint.
|
|
264
|
+
*/
|
|
265
|
+
export const isCrossDatabaseJoin = (
|
|
266
|
+
join: Join,
|
|
267
|
+
database: Database,
|
|
268
|
+
): boolean => {
|
|
269
|
+
const firstPair = join.aliases[0];
|
|
270
|
+
if (!firstPair) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
const sourceOwner = firstPair.first.relation.value.schema._OWNER;
|
|
274
|
+
const targetOwner = firstPair.second.relation.value.schema._OWNER;
|
|
275
|
+
return sourceOwner !== database || targetOwner !== database;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Number of column rows a relation's table-node will render. Tables expose
|
|
280
|
+
* Column[] via `columns`, Views expose ColumnMapping[] via `columnMappings`.
|
|
281
|
+
*/
|
|
282
|
+
export const getRelationColumnCount = (relation: DatabaseRelation): number =>
|
|
283
|
+
isView(relation)
|
|
284
|
+
? relation.columnMappings.length
|
|
285
|
+
: getTableColumns(relation).length;
|
|
286
|
+
|
|
287
|
+
const collectColumnsFromOperation = (
|
|
288
|
+
// The operation tree includes DynaFunction with parameters, TableAliasColumn,
|
|
289
|
+
// Literal, etc. We descend it loosely to collect any column references.
|
|
290
|
+
operation: RelationalOperationElement | undefined,
|
|
291
|
+
acc: Set<Column>,
|
|
292
|
+
): void => {
|
|
293
|
+
if (operation === undefined) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
// TableAliasColumn has a `column.value: Column`
|
|
297
|
+
const candidate = operation as {
|
|
298
|
+
column?: { value?: Column };
|
|
299
|
+
parameters?: RelationalOperationElement[];
|
|
300
|
+
};
|
|
301
|
+
if (candidate.column?.value) {
|
|
302
|
+
acc.add(candidate.column.value);
|
|
303
|
+
}
|
|
304
|
+
if (Array.isArray(candidate.parameters)) {
|
|
305
|
+
candidate.parameters.forEach((p) => collectColumnsFromOperation(p, acc));
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Identify columns that participate in any join in the database. Used to badge
|
|
311
|
+
* columns as foreign keys in the table node.
|
|
312
|
+
*
|
|
313
|
+
* Note: in Pure relational, joins ARE the relationships — there is no separate
|
|
314
|
+
* FK constraint on the column. A column is "FK-like" iff some join's operation
|
|
315
|
+
* references it.
|
|
316
|
+
*/
|
|
317
|
+
export const collectForeignKeyColumns = (database: Database): Set<Column> => {
|
|
318
|
+
const fkColumns = new Set<Column>();
|
|
319
|
+
database.joins.forEach((join) => {
|
|
320
|
+
collectColumnsFromOperation(join.operation, fkColumns);
|
|
321
|
+
});
|
|
322
|
+
return fkColumns;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
export interface DatabaseDiagramJoinEdge {
|
|
326
|
+
/** Stable id used by React Flow. */
|
|
327
|
+
id: string;
|
|
328
|
+
/** Join name (used as edge label). */
|
|
329
|
+
name: string;
|
|
330
|
+
/** Source relation id (`<schema>.<name>`). */
|
|
331
|
+
source: string;
|
|
332
|
+
/** Target relation id (`<schema>.<name>`). */
|
|
333
|
+
target: string;
|
|
334
|
+
/** Original `Join` reference for identity-based selection matching. */
|
|
335
|
+
join: Join;
|
|
336
|
+
/** True when both endpoints are the same relation (self-join). React Flow
|
|
337
|
+
* renders these as loop edges; we use this flag so the canvas can pick a
|
|
338
|
+
* distinct edge type / styling without re-walking aliases. */
|
|
339
|
+
isSelfJoin: boolean;
|
|
340
|
+
/** True when at least one endpoint is *not* in this database. The missing
|
|
341
|
+
* endpoint is rendered as a stub placeholder node instead of a real
|
|
342
|
+
* table-node so users can still see the relationship at a glance. */
|
|
343
|
+
isCrossDatabase: boolean;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Synthetic relation id used for the placeholder node that stands in for a
|
|
347
|
+
* cross-database join's foreign endpoint. Includes the schema-qualified
|
|
348
|
+
* source path so the same foreign relation is reused across multiple joins
|
|
349
|
+
* rather than producing one stub per join. */
|
|
350
|
+
export const getForeignRelationStubId = (
|
|
351
|
+
ownerPath: string,
|
|
352
|
+
schemaName: string,
|
|
353
|
+
relationName: string,
|
|
354
|
+
): string => `__foreign__:${ownerPath}::${schemaName}.${relationName}`;
|
|
355
|
+
|
|
356
|
+
export interface ForeignRelationStub {
|
|
357
|
+
id: string;
|
|
358
|
+
schemaName: string;
|
|
359
|
+
relationName: string;
|
|
360
|
+
ownerPath: string;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export interface DatabaseDiagramBuildResult {
|
|
364
|
+
edges: DatabaseDiagramJoinEdge[];
|
|
365
|
+
/** Foreign endpoints that need a placeholder node on the canvas. Empty
|
|
366
|
+
* when there are no cross-database joins. Deduplicated by stub id. */
|
|
367
|
+
foreignStubs: ForeignRelationStub[];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Walk all joins in the database and produce a deduplicated list of edges
|
|
372
|
+
* between relations (tables and/or views), including self-joins (rendered
|
|
373
|
+
* as loop edges) and cross-database joins (whose foreign endpoint becomes
|
|
374
|
+
* a placeholder stub).
|
|
375
|
+
*
|
|
376
|
+
* Implementation notes:
|
|
377
|
+
* - `Join.aliases` typically contains both directions `(A→B)` and `(B→A)` as a
|
|
378
|
+
* lookup optimization. We treat A↔B as a single edge and use the first alias.
|
|
379
|
+
* - Self-joins (A↔A) are kept and flagged via `isSelfJoin` so the canvas can
|
|
380
|
+
* render a loop edge instead of skipping them.
|
|
381
|
+
* - Joins whose endpoints reference relations outside this database (typically
|
|
382
|
+
* through `includes` / `includedStoreSpecifications`) are kept and flagged
|
|
383
|
+
* via `isCrossDatabase`. The foreign endpoint is replaced with a stub id so
|
|
384
|
+
* the caller can render a small placeholder node beside the in-DB endpoint.
|
|
385
|
+
*/
|
|
386
|
+
export const buildJoinEdges = (
|
|
387
|
+
database: Database,
|
|
388
|
+
): DatabaseDiagramBuildResult => {
|
|
389
|
+
const ownIds = new Set<string>();
|
|
390
|
+
database.schemas.forEach((schema) => {
|
|
391
|
+
schema.tables.forEach((table) => ownIds.add(getRelationId(table)));
|
|
392
|
+
schema.views.forEach((view) => ownIds.add(getRelationId(view)));
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const edges: DatabaseDiagramJoinEdge[] = [];
|
|
396
|
+
const stubs = new Map<string, ForeignRelationStub>();
|
|
397
|
+
database.joins.forEach((join: Join) => {
|
|
398
|
+
const firstPair = join.aliases[0];
|
|
399
|
+
if (!firstPair) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const sourceRelation = firstPair.first.relation.value;
|
|
403
|
+
const targetRelation = firstPair.second.relation.value;
|
|
404
|
+
const rawSourceId = `${sourceRelation.schema.name}.${sourceRelation.name}`;
|
|
405
|
+
const rawTargetId = `${targetRelation.schema.name}.${targetRelation.name}`;
|
|
406
|
+
const sourceInOwn = ownIds.has(rawSourceId);
|
|
407
|
+
const targetInOwn = ownIds.has(rawTargetId);
|
|
408
|
+
|
|
409
|
+
// Resolve each endpoint to either its real node id (when in-DB) or a
|
|
410
|
+
// stub id (when foreign). For pure self-joins we keep both ids as the
|
|
411
|
+
// same real id so React Flow draws a loop on that node.
|
|
412
|
+
let sourceId = rawSourceId;
|
|
413
|
+
if (!sourceInOwn) {
|
|
414
|
+
const ownerPath = sourceRelation.schema._OWNER.path;
|
|
415
|
+
const stubId = getForeignRelationStubId(
|
|
416
|
+
ownerPath,
|
|
417
|
+
sourceRelation.schema.name,
|
|
418
|
+
sourceRelation.name,
|
|
419
|
+
);
|
|
420
|
+
sourceId = stubId;
|
|
421
|
+
if (!stubs.has(stubId)) {
|
|
422
|
+
stubs.set(stubId, {
|
|
423
|
+
id: stubId,
|
|
424
|
+
schemaName: sourceRelation.schema.name,
|
|
425
|
+
relationName: sourceRelation.name,
|
|
426
|
+
ownerPath,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
let targetId = rawTargetId;
|
|
431
|
+
if (!targetInOwn) {
|
|
432
|
+
const ownerPath = targetRelation.schema._OWNER.path;
|
|
433
|
+
const stubId = getForeignRelationStubId(
|
|
434
|
+
ownerPath,
|
|
435
|
+
targetRelation.schema.name,
|
|
436
|
+
targetRelation.name,
|
|
437
|
+
);
|
|
438
|
+
targetId = stubId;
|
|
439
|
+
if (!stubs.has(stubId)) {
|
|
440
|
+
stubs.set(stubId, {
|
|
441
|
+
id: stubId,
|
|
442
|
+
schemaName: targetRelation.schema.name,
|
|
443
|
+
relationName: targetRelation.name,
|
|
444
|
+
ownerPath,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
edges.push({
|
|
450
|
+
id: `join:${join.name}`,
|
|
451
|
+
name: join.name,
|
|
452
|
+
source: sourceId,
|
|
453
|
+
target: targetId,
|
|
454
|
+
join,
|
|
455
|
+
isSelfJoin: sourceInOwn && targetInOwn && sourceId === targetId,
|
|
456
|
+
isCrossDatabase: !sourceInOwn || !targetInOwn,
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
return { edges, foreignStubs: Array.from(stubs.values()) };
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
export interface LaidOutNode {
|
|
463
|
+
id: string;
|
|
464
|
+
x: number;
|
|
465
|
+
y: number;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export interface DatabaseDiagramRelationNode {
|
|
469
|
+
id: string;
|
|
470
|
+
relation: DatabaseRelation;
|
|
471
|
+
/** Estimated height in pixels — driven by column count for dagre layout. */
|
|
472
|
+
estimatedHeight: number;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const NODE_WIDTH = 240;
|
|
476
|
+
const NODE_HEADER_HEIGHT = 36;
|
|
477
|
+
const NODE_COL_HEIGHT = 22;
|
|
478
|
+
const NODE_PADDING = 8;
|
|
479
|
+
|
|
480
|
+
export const estimateNodeHeight = (relation: DatabaseRelation): number =>
|
|
481
|
+
NODE_HEADER_HEIGHT +
|
|
482
|
+
getRelationColumnCount(relation) * NODE_COL_HEIGHT +
|
|
483
|
+
NODE_PADDING;
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Run dagre on the relation/edge graph and return positions keyed by
|
|
487
|
+
* relation id. Uses left-to-right layering, which suits ERDs better than
|
|
488
|
+
* top-down.
|
|
489
|
+
*/
|
|
490
|
+
export const layoutDatabaseDiagram = (
|
|
491
|
+
nodes: DatabaseDiagramRelationNode[],
|
|
492
|
+
edges: DatabaseDiagramJoinEdge[],
|
|
493
|
+
): Map<string, LaidOutNode> => {
|
|
494
|
+
const g = new dagre.graphlib.Graph<{ width: number; height: number }>();
|
|
495
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
496
|
+
g.setGraph({
|
|
497
|
+
rankdir: 'LR',
|
|
498
|
+
nodesep: 60,
|
|
499
|
+
ranksep: 120,
|
|
500
|
+
marginx: 20,
|
|
501
|
+
marginy: 20,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
nodes.forEach((node) => {
|
|
505
|
+
g.setNode(node.id, { width: NODE_WIDTH, height: node.estimatedHeight });
|
|
506
|
+
});
|
|
507
|
+
edges.forEach((edge) => {
|
|
508
|
+
g.setEdge(edge.source, edge.target);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
dagre.layout(g);
|
|
512
|
+
|
|
513
|
+
const out = new Map<string, LaidOutNode>();
|
|
514
|
+
nodes.forEach((node) => {
|
|
515
|
+
const laidOut = g.node(node.id) as
|
|
516
|
+
| { x: number; y: number; width: number; height: number }
|
|
517
|
+
| undefined;
|
|
518
|
+
if (laidOut) {
|
|
519
|
+
// dagre returns center; React Flow expects top-left.
|
|
520
|
+
out.set(node.id, {
|
|
521
|
+
id: node.id,
|
|
522
|
+
x: laidOut.x - laidOut.width / 2,
|
|
523
|
+
y: laidOut.y - laidOut.height / 2,
|
|
524
|
+
});
|
|
525
|
+
} else {
|
|
526
|
+
out.set(node.id, { id: node.id, x: 0, y: 0 });
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
return out;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Flat list of (schema, relation) pairs in deterministic order — used by the
|
|
534
|
+
* canvas builder. Tables come before views within each schema (alphabetic
|
|
535
|
+
* within each kind), so the canvas layout stays stable as a database grows.
|
|
536
|
+
*/
|
|
537
|
+
export const getOrderedRelations = (
|
|
538
|
+
database: Database,
|
|
539
|
+
): { schema: Schema; relation: DatabaseRelation }[] => {
|
|
540
|
+
const pairs: { schema: Schema; relation: DatabaseRelation }[] = [];
|
|
541
|
+
database.schemas
|
|
542
|
+
.slice()
|
|
543
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
544
|
+
.forEach((schema) => {
|
|
545
|
+
schema.tables
|
|
546
|
+
.slice()
|
|
547
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
548
|
+
.forEach((table) => pairs.push({ schema, relation: table }));
|
|
549
|
+
schema.views
|
|
550
|
+
.slice()
|
|
551
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
552
|
+
.forEach((view) => pairs.push({ schema, relation: view }));
|
|
553
|
+
});
|
|
554
|
+
return pairs;
|
|
555
|
+
};
|