@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.
Files changed (61) hide show
  1. package/lib/__lib__/LegendStudioUserDataHelper.d.ts +4 -1
  2. package/lib/__lib__/LegendStudioUserDataHelper.d.ts.map +1 -1
  3. package/lib/__lib__/LegendStudioUserDataHelper.js +18 -0
  4. package/lib/__lib__/LegendStudioUserDataHelper.js.map +1 -1
  5. package/lib/components/editor/editor-group/EditorGroup.d.ts.map +1 -1
  6. package/lib/components/editor/editor-group/EditorGroup.js +5 -0
  7. package/lib/components/editor/editor-group/EditorGroup.js.map +1 -1
  8. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.d.ts.map +1 -1
  9. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js +13 -44
  10. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js.map +1 -1
  11. package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.d.ts +26 -0
  12. package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.d.ts.map +1 -0
  13. package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.js +101 -0
  14. package/lib/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.js.map +1 -0
  15. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.d.ts +23 -0
  16. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.d.ts.map +1 -0
  17. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.js +434 -0
  18. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.js.map +1 -0
  19. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.d.ts +242 -0
  20. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.d.ts.map +1 -0
  21. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.js +371 -0
  22. package/lib/components/editor/editor-group/database-editor/DatabaseDiagramHelper.js.map +1 -0
  23. package/lib/components/editor/editor-group/database-editor/DatabaseEditor.d.ts +29 -0
  24. package/lib/components/editor/editor-group/database-editor/DatabaseEditor.d.ts.map +1 -0
  25. package/lib/components/editor/editor-group/database-editor/DatabaseEditor.js +78 -0
  26. package/lib/components/editor/editor-group/database-editor/DatabaseEditor.js.map +1 -0
  27. package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.d.ts +30 -0
  28. package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.d.ts.map +1 -0
  29. package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.js +331 -0
  30. package/lib/components/editor/editor-group/database-editor/DatabaseSchemaTree.js.map +1 -0
  31. package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.d.ts +104 -0
  32. package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.d.ts.map +1 -0
  33. package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.js +151 -0
  34. package/lib/components/editor/editor-group/database-editor/DatabaseTableNode.js.map +1 -0
  35. package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.d.ts.map +1 -1
  36. package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.js +3 -78
  37. package/lib/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.js.map +1 -1
  38. package/lib/index.css +2 -2
  39. package/lib/index.css.map +1 -1
  40. package/lib/package.json +4 -1
  41. package/lib/stores/editor/EditorTabManagerState.d.ts.map +1 -1
  42. package/lib/stores/editor/EditorTabManagerState.js +5 -3
  43. package/lib/stores/editor/EditorTabManagerState.js.map +1 -1
  44. package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.d.ts +252 -0
  45. package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.d.ts.map +1 -0
  46. package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.js +755 -0
  47. package/lib/stores/editor/editor-state/element-editor-state/DatabaseEditorState.js.map +1 -0
  48. package/package.json +12 -9
  49. package/src/__lib__/LegendStudioUserDataHelper.ts +30 -0
  50. package/src/components/editor/editor-group/EditorGroup.tsx +4 -0
  51. package/src/components/editor/editor-group/dataProduct/DataProductEditor.tsx +0 -52
  52. package/src/components/editor/editor-group/database-editor/DatabaseAnnotationDisplay.tsx +200 -0
  53. package/src/components/editor/editor-group/database-editor/DatabaseDiagramCanvas.tsx +701 -0
  54. package/src/components/editor/editor-group/database-editor/DatabaseDiagramHelper.ts +555 -0
  55. package/src/components/editor/editor-group/database-editor/DatabaseEditor.tsx +246 -0
  56. package/src/components/editor/editor-group/database-editor/DatabaseSchemaTree.tsx +1053 -0
  57. package/src/components/editor/editor-group/database-editor/DatabaseTableNode.tsx +465 -0
  58. package/src/components/editor/editor-group/ingest-editor/IngestDefinitionEditor.tsx +2 -242
  59. package/src/stores/editor/EditorTabManagerState.ts +4 -5
  60. package/src/stores/editor/editor-state/element-editor-state/DatabaseEditorState.ts +938 -0
  61. 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
+ };