@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,938 @@
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 {
18
+ action,
19
+ computed,
20
+ flow,
21
+ flowResult,
22
+ makeObservable,
23
+ observable,
24
+ } from 'mobx';
25
+ import { ElementEditorState } from './ElementEditorState.js';
26
+ import {
27
+ type GeneratorFn,
28
+ LogEvent,
29
+ assertErrorThrown,
30
+ guaranteeType,
31
+ noop,
32
+ } from '@finos/legend-shared';
33
+ import {
34
+ type Column,
35
+ Database,
36
+ type Filter,
37
+ GRAPH_MANAGER_EVENT,
38
+ type Join,
39
+ type PackageableElement,
40
+ type RawRelationalOperationElement,
41
+ type Table,
42
+ type View,
43
+ } from '@finos/legend-graph';
44
+ import type { EditorStore } from '../../EditorStore.js';
45
+ import { LegendStudioUserDataHelper } from '../../../../__lib__/LegendStudioUserDataHelper.js';
46
+
47
+ /**
48
+ * Top-level tabs inside the Database form-mode editor. `VIEW` shows the ERD
49
+ * canvas; `GRAMMAR` shows a read-only preview of the same Pure DSL grammar
50
+ * that the global Text Mode would render.
51
+ */
52
+ export enum DATABASE_EDITOR_TAB {
53
+ VIEW = 'VIEW',
54
+ GRAMMAR = 'GRAMMAR',
55
+ }
56
+
57
+ /**
58
+ * Stable id for an element of the schema tree. We use a string id (rather than
59
+ * holding object references) so the side-panel's expand/collapse state can
60
+ * survive reprocessing without any cross-instance bookkeeping.
61
+ *
62
+ * Tables and Views share the same id namespace because they share the same
63
+ * `<schema>.<name>` qualifier — the metamodel doesn't allow a table and a view
64
+ * to collide on name within a schema.
65
+ */
66
+ export const getSchemaNodeId = (schemaName: string): string => schemaName;
67
+ export const getRelationNodeId = (
68
+ schemaName: string,
69
+ relationName: string,
70
+ ): string => `${schemaName}.${relationName}`;
71
+
72
+ /**
73
+ * Stable key used to look up the Pure-code formula for a single view column
74
+ * inside `DatabaseEditorState.viewColumnFormulas`. Joins schema, view, and
75
+ * column so it survives reprocessing and can't collide across schemas.
76
+ */
77
+ export const getViewColumnFormulaKey = (
78
+ schemaName: string,
79
+ viewName: string,
80
+ columnName: string,
81
+ ): string => `${schemaName}.${viewName}.${columnName}`;
82
+
83
+ /**
84
+ * Stable key used to look up the Pure-code formula for a single filter inside
85
+ * `DatabaseEditorState.filterFormulas`. Filter names are unique within a
86
+ * Database (they live as a flat `Database.filters: Filter[]`), so the name
87
+ * alone is sufficient — no schema qualifier needed.
88
+ */
89
+ export const getFilterFormulaKey = (filterName: string): string => filterName;
90
+
91
+ /**
92
+ * Stable key used to look up the Pure-code formula for a single join. Join
93
+ * names are unique within a Database (they live as a flat
94
+ * `Database.joins: Join[]`), so the name alone is sufficient. Mirrors the
95
+ * filter helper above.
96
+ */
97
+ export const getJoinFormulaKey = (joinName: string): string => joinName;
98
+
99
+ /**
100
+ * Stable key used to look up the Pure-code formula for a single view's
101
+ * groupBy column expression. Views are scoped to a schema and groupBy
102
+ * positions matter (the engine renders them in declaration order), so the
103
+ * key includes both the schema-qualified view name and the position index.
104
+ */
105
+ export const getViewGroupByFormulaKey = (
106
+ schemaName: string,
107
+ viewName: string,
108
+ index: number,
109
+ ): string => `${schemaName}.${viewName}.groupBy[${index}]`;
110
+
111
+ /**
112
+ * Walk the V1-shaped Database entity content and collect every view's column
113
+ * mapping operations into a Map keyed by `<schema>.<view>.<column>`. The
114
+ * traversal mirrors the V1_Database / V1_Schema / V1_View / V1_ColumnMapping
115
+ * shape — anything that doesn't match that shape is skipped silently.
116
+ *
117
+ * We use loose typing because the entity content is `Record<PropertyKey,
118
+ * unknown>` and the V1 protocol types aren't exported from `@finos/legend-
119
+ * graph` for direct use here.
120
+ */
121
+ const collectRawViewColumnOperations = (
122
+ content: unknown,
123
+ ): Map<string, RawRelationalOperationElement> => {
124
+ const out = new Map<string, RawRelationalOperationElement>();
125
+ const dbContent = content as { schemas?: unknown[] } | undefined;
126
+ if (!dbContent || !Array.isArray(dbContent.schemas)) {
127
+ return out;
128
+ }
129
+ for (const schemaJson of dbContent.schemas) {
130
+ const schema = schemaJson as
131
+ | { name?: string; views?: unknown[] }
132
+ | undefined;
133
+ if (!schema || typeof schema.name !== 'string') {
134
+ continue;
135
+ }
136
+ const views = Array.isArray(schema.views) ? schema.views : [];
137
+ for (const viewJson of views) {
138
+ const view = viewJson as
139
+ | { name?: string; columnMappings?: unknown[] }
140
+ | undefined;
141
+ if (!view || typeof view.name !== 'string') {
142
+ continue;
143
+ }
144
+ const mappings = Array.isArray(view.columnMappings)
145
+ ? view.columnMappings
146
+ : [];
147
+ for (const mappingJson of mappings) {
148
+ const mapping = mappingJson as
149
+ | { name?: string; operation?: unknown }
150
+ | undefined;
151
+ if (
152
+ !mapping ||
153
+ typeof mapping.name !== 'string' ||
154
+ typeof mapping.operation !== 'object' ||
155
+ mapping.operation === null
156
+ ) {
157
+ continue;
158
+ }
159
+ out.set(
160
+ getViewColumnFormulaKey(schema.name, view.name, mapping.name),
161
+ mapping.operation,
162
+ );
163
+ }
164
+ }
165
+ }
166
+ return out;
167
+ };
168
+
169
+ /**
170
+ * Walk the V1-shaped Database entity content and collect every view's
171
+ * groupBy column expressions into a Map keyed by
172
+ * `<schema>.<view>.groupBy[<index>]`. The V1 shape is
173
+ * `schemas[].views[].groupBy.columns[]: RawRelationalOperationElement`.
174
+ * Anything that doesn't match that shape is skipped silently. Same loose
175
+ * typing as the column-mapping walker above.
176
+ */
177
+ const collectRawViewGroupByOperations = (
178
+ content: unknown,
179
+ ): Map<string, RawRelationalOperationElement> => {
180
+ const out = new Map<string, RawRelationalOperationElement>();
181
+ const dbContent = content as { schemas?: unknown[] } | undefined;
182
+ if (!dbContent || !Array.isArray(dbContent.schemas)) {
183
+ return out;
184
+ }
185
+ for (const schemaJson of dbContent.schemas) {
186
+ const schema = schemaJson as
187
+ | { name?: string; views?: unknown[] }
188
+ | undefined;
189
+ if (!schema || typeof schema.name !== 'string') {
190
+ continue;
191
+ }
192
+ const views = Array.isArray(schema.views) ? schema.views : [];
193
+ for (const viewJson of views) {
194
+ // V1 protocol stores `View.groupBy` as a flat array of operation
195
+ // elements (see `V1_View.groupBy: V1_RelationalOperationElement[]`),
196
+ // NOT wrapped in `{ columns: [...] }` like the metamodel side. The
197
+ // serializer drops the field entirely when there are no group-by
198
+ // columns, so `view.groupBy` may legitimately be undefined.
199
+ const view = viewJson as
200
+ | { name?: string; groupBy?: unknown[] }
201
+ | undefined;
202
+ if (
203
+ !view ||
204
+ typeof view.name !== 'string' ||
205
+ !Array.isArray(view.groupBy)
206
+ ) {
207
+ continue;
208
+ }
209
+ view.groupBy.forEach((opJson, index) => {
210
+ if (typeof opJson !== 'object' || opJson === null) {
211
+ return;
212
+ }
213
+ out.set(
214
+ getViewGroupByFormulaKey(
215
+ schema.name as string,
216
+ view.name as string,
217
+ index,
218
+ ),
219
+ opJson,
220
+ );
221
+ });
222
+ }
223
+ }
224
+ return out;
225
+ };
226
+
227
+ /**
228
+ * Walk the V1-shaped Database entity content and collect every filter's
229
+ * operation into a Map keyed by filter name. Filters live at the database
230
+ * level (a flat `filters[]` on the V1 root), unlike views which are nested
231
+ * inside schemas — so this walker is shallower than
232
+ * `collectRawViewColumnOperations`.
233
+ *
234
+ * Anything that doesn't match the expected `{ name, operation }` shape is
235
+ * skipped silently. Loose typing for the same reason as the view walker:
236
+ * entity content is `Record<PropertyKey, unknown>`.
237
+ */
238
+ const collectRawFilterOperations = (
239
+ content: unknown,
240
+ ): Map<string, RawRelationalOperationElement> => {
241
+ const out = new Map<string, RawRelationalOperationElement>();
242
+ const dbContent = content as { filters?: unknown[] } | undefined;
243
+ if (!dbContent || !Array.isArray(dbContent.filters)) {
244
+ return out;
245
+ }
246
+ for (const filterJson of dbContent.filters) {
247
+ const filter = filterJson as
248
+ | { name?: string; operation?: unknown }
249
+ | undefined;
250
+ if (
251
+ !filter ||
252
+ typeof filter.name !== 'string' ||
253
+ typeof filter.operation !== 'object' ||
254
+ filter.operation === null
255
+ ) {
256
+ continue;
257
+ }
258
+ out.set(getFilterFormulaKey(filter.name), filter.operation);
259
+ }
260
+ return out;
261
+ };
262
+
263
+ /**
264
+ * Walk the V1-shaped Database entity content and collect every join's
265
+ * operation into a Map keyed by join name. Joins live at the database level
266
+ * (a flat `joins[]` on the V1 root) under the shape `{ name, operation }`,
267
+ * mirroring filters. Anything that doesn't match the expected shape is
268
+ * skipped silently.
269
+ */
270
+ const collectRawJoinOperations = (
271
+ content: unknown,
272
+ ): Map<string, RawRelationalOperationElement> => {
273
+ const out = new Map<string, RawRelationalOperationElement>();
274
+ const dbContent = content as { joins?: unknown[] } | undefined;
275
+ if (!dbContent || !Array.isArray(dbContent.joins)) {
276
+ return out;
277
+ }
278
+ for (const joinJson of dbContent.joins) {
279
+ const join = joinJson as { name?: string; operation?: unknown } | undefined;
280
+ if (
281
+ !join ||
282
+ typeof join.name !== 'string' ||
283
+ typeof join.operation !== 'object' ||
284
+ join.operation === null
285
+ ) {
286
+ continue;
287
+ }
288
+ out.set(getJoinFormulaKey(join.name), join.operation);
289
+ }
290
+ return out;
291
+ };
292
+
293
+ /**
294
+ * View-only form mode for `Database` elements. Renders an ERD-style canvas
295
+ * (tables as nodes, joins as edges) plus a side-panel tree of schemas/tables/
296
+ * columns. A second tab shows the same grammar that Text Mode would show.
297
+ *
298
+ * Layout positions are derived per-render via dagre and not persisted yet —
299
+ * persistence + edit support are intended follow-ups.
300
+ */
301
+ export class DatabaseEditorState extends ElementEditorState {
302
+ selectedTab: DATABASE_EDITOR_TAB = DATABASE_EDITOR_TAB.VIEW;
303
+
304
+ // ---- Selection -----------------------------------------------------------
305
+ // Mutually exclusive selection axes:
306
+ // - `selectedRelation` (+ optional `selectedColumn`): a Table or View is
307
+ // the focus. Drives the blue ring on the canvas node and side-panel row.
308
+ // - `selectedJoin`: a join is the focus. Drives the yellow edge style on
309
+ // the canvas and yellow rings on both endpoint relations ("join
310
+ // endpoints"), plus the side-panel join row highlight.
311
+ // - `selectedFilter`: a database-level filter is the focus. Filters don't
312
+ // have a canvas representation in the MVP — they live in the side panel
313
+ // only — so this only drives the side-panel row highlight.
314
+ // Setting one clears the others — see action implementations below.
315
+ //
316
+ // `selectedColumn` is only meaningful when `selectedRelation` is a Table —
317
+ // for views, column-mappings aren't `Column` instances and we don't support
318
+ // per-mapping highlight in the MVP.
319
+ selectedRelation: Table | View | undefined;
320
+ selectedColumn: Column | undefined;
321
+ selectedJoin: Join | undefined;
322
+ selectedFilter: Filter | undefined;
323
+
324
+ // For views (which have `columnMappings`, not `Column` instances), column
325
+ // selection is tracked by name. Mutually exclusive with `selectedColumn`
326
+ // — a relation is either a Table (use `selectedColumn`) or a View (use
327
+ // `selectedViewColumnName`); never both. Always `undefined` when the
328
+ // selected relation is a Table.
329
+ selectedViewColumnName: string | undefined;
330
+
331
+ // ---- Side-panel expansion -----------------------------------------------
332
+ // Schemas default to expanded so users immediately see their relations;
333
+ // tables/views default to collapsed so the tree isn't overwhelming on
334
+ // large databases.
335
+ expandedSchemaIds = new Set<string>();
336
+ expandedRelationIds = new Set<string>();
337
+
338
+ // ---- Pan-to-selected trigger --------------------------------------------
339
+ // Selecting a row in the side panel should pan the canvas to that table;
340
+ // selecting a node directly on the canvas should NOT (the user is already
341
+ // looking at it). Rather than coupling the two components through callbacks,
342
+ // we increment this counter on side-panel actions and let the canvas
343
+ // observe it via `useEffect`.
344
+ panToSelectedRequestCounter = 0;
345
+
346
+ // ---- Canvas action triggers --------------------------------------------
347
+ // Same counter pattern as `panToSelectedRequestCounter` for one-shot
348
+ // actions the side-panel header / canvas toolbar fire and the canvas
349
+ // executes. Counters (rather than booleans) so identical successive
350
+ // requests still trigger.
351
+ fitAllRequestCounter = 0;
352
+ resetLayoutRequestCounter = 0;
353
+
354
+ // ---- Tree search --------------------------------------------------------
355
+ // User-entered filter applied to the schema tree (schema / table / view /
356
+ // column names). Empty string = no filter. Lowercase comparison is done
357
+ // at the consumer side via `searchTextLowerCase` so we don't pay the
358
+ // toLowerCase cost in every row render.
359
+ searchText = '';
360
+
361
+ // ---- View-column Pure-code formulas -------------------------------------
362
+ // Populated lazily by `loadViewColumnFormulas()` (one batched engine call
363
+ // per Database load). Until populated — or for column mappings the engine
364
+ // can't render — the UI falls back to the static placeholder
365
+ // ("calculate [...]") so views always render something useful.
366
+ // Keyed by `getViewColumnFormulaKey(schema, view, column)`.
367
+ viewColumnFormulas = new Map<string, string>();
368
+ isLoadingViewColumnFormulas = false;
369
+
370
+ // ---- Filter Pure-code formulas ------------------------------------------
371
+ // Same pattern as `viewColumnFormulas` but for `Database.filters[].operation`.
372
+ // Populated lazily by `loadFilterFormulas()` in a single batched engine call.
373
+ // Keyed by filter name (filters are unique within a database).
374
+ filterFormulas = new Map<string, string>();
375
+ isLoadingFilterFormulas = false;
376
+
377
+ // ---- Join Pure-code formulas --------------------------------------------
378
+ // Same pattern as `viewColumnFormulas` and `filterFormulas` but for
379
+ // `Database.joins[].operation`. Populated lazily by `loadJoinFormulas()` in
380
+ // a single batched engine call. Surfaced in the side panel under each join
381
+ // row and in the canvas "selected join" floating card.
382
+ joinFormulas = new Map<string, string>();
383
+ isLoadingJoinFormulas = false;
384
+
385
+ // ---- View groupBy Pure-code formulas ------------------------------------
386
+ // Same pattern as `viewColumnFormulas` but for `View.groupBy.columns`.
387
+ // Populated lazily by `loadViewGroupByFormulas()` in a single batched
388
+ // engine call. Keyed by `<schema>.<view>.groupBy[<index>]` so positional
389
+ // order is preserved (groupBy expressions are positional).
390
+ viewGroupByFormulas = new Map<string, string>();
391
+ isLoadingViewGroupByFormulas = false;
392
+
393
+ // ---- Layout -------------------------------------------------------------
394
+ // Side-panel (schema tree) is user-resizable on the canvas. We keep its
395
+ // collapsed state on the editor state so it survives tab switches and so
396
+ // a future toggle button outside the panel can drive it. Width is owned
397
+ // by `react-reflex` and not tracked here — only the binary collapse flag.
398
+ isSidePanelCollapsed = false;
399
+
400
+ // ---- Theme --------------------------------------------------------------
401
+ // The wider Studio app is dark-mode-only today. We allow this editor (and
402
+ // only this editor) to opt into a light theme via a toolbar toggle. The
403
+ // setting lives on the editor state so it survives tab switches and
404
+ // recompiles within the same session, and is persisted per-user via
405
+ // `UserDataService` (localStorage) so the choice survives reloads.
406
+ // TODO: when Studio adopts app-wide theming via `LayoutService` (Query
407
+ // already does this with `setColorTheme(..., { persist: true })`), drop
408
+ // this local observable + persistence and react to
409
+ // `applicationStore.layoutService.currentColorTheme` instead so this
410
+ // editor stays in sync with the rest of the app.
411
+ theme: 'dark' | 'light' = 'dark';
412
+
413
+ constructor(editorStore: EditorStore, element: PackageableElement) {
414
+ super(editorStore, element);
415
+
416
+ makeObservable(this, {
417
+ selectedTab: observable,
418
+ selectedRelation: observable,
419
+ selectedColumn: observable,
420
+ selectedViewColumnName: observable,
421
+ selectedJoin: observable,
422
+ selectedFilter: observable,
423
+ expandedSchemaIds: observable,
424
+ expandedRelationIds: observable,
425
+ panToSelectedRequestCounter: observable,
426
+ fitAllRequestCounter: observable,
427
+ resetLayoutRequestCounter: observable,
428
+ searchText: observable,
429
+ viewColumnFormulas: observable,
430
+ isLoadingViewColumnFormulas: observable,
431
+ filterFormulas: observable,
432
+ isLoadingFilterFormulas: observable,
433
+ joinFormulas: observable,
434
+ isLoadingJoinFormulas: observable,
435
+ viewGroupByFormulas: observable,
436
+ isLoadingViewGroupByFormulas: observable,
437
+ isSidePanelCollapsed: observable,
438
+ theme: observable,
439
+ database: computed,
440
+ setSelectedTab: action,
441
+ setSelectedRelation: action,
442
+ focusOnRelation: action,
443
+ focusOnColumn: action,
444
+ focusOnViewColumn: action,
445
+ focusOnJoin: action,
446
+ focusOnFilter: action,
447
+ clearSelection: action,
448
+ toggleSchemaExpanded: action,
449
+ toggleRelationExpanded: action,
450
+ expandAllSchemas: action,
451
+ collapseAll: action,
452
+ setSidePanelCollapsed: action,
453
+ toggleSidePanelCollapsed: action,
454
+ toggleTheme: action,
455
+ setSearchText: action,
456
+ requestFitAll: action,
457
+ requestResetLayout: action,
458
+ generateGrammarText: flow,
459
+ loadViewColumnFormulas: flow,
460
+ loadFilterFormulas: flow,
461
+ loadJoinFormulas: flow,
462
+ loadViewGroupByFormulas: flow,
463
+ });
464
+
465
+ // Default: every schema starts expanded so the tree is immediately useful.
466
+ this.database.schemas.forEach((schema) => {
467
+ this.expandedSchemaIds.add(getSchemaNodeId(schema.name));
468
+ });
469
+
470
+ // Hydrate the persisted theme preference (if any). Falls through to the
471
+ // default 'dark' when the user has never set it. Done synchronously in
472
+ // the constructor so the first render already reflects the stored choice.
473
+ const persistedTheme = LegendStudioUserDataHelper.databaseEditor_getTheme(
474
+ this.editorStore.applicationStore.userDataService,
475
+ );
476
+ if (persistedTheme) {
477
+ this.theme = persistedTheme;
478
+ }
479
+
480
+ // Kick off the formula load eagerly. The flow is async and writes into
481
+ // `viewColumnFormulas` when ready — components render with the placeholder
482
+ // until then, so the UI is never blocked on this. Errors are logged and
483
+ // swallowed; the placeholder remains.
484
+ flowResult(this.loadViewColumnFormulas()).catch(noop());
485
+
486
+ // Same for filter formulas — independent batched engine call so the two
487
+ // loads run in parallel.
488
+ flowResult(this.loadFilterFormulas()).catch(noop());
489
+
490
+ // And join formulas — third independent batched engine call. All three
491
+ // are fire-and-forget on construction; consumers fall back to the
492
+ // placeholder text until they resolve.
493
+ flowResult(this.loadJoinFormulas()).catch(noop());
494
+
495
+ // And view-groupBy formulas — fourth independent batched engine call.
496
+ // Only fires for databases that actually have at least one view with a
497
+ // groupBy; the loader bails out otherwise.
498
+ flowResult(this.loadViewGroupByFormulas()).catch(noop());
499
+ }
500
+
501
+ get database(): Database {
502
+ return guaranteeType(
503
+ this.element,
504
+ Database,
505
+ 'Element inside database editor state must be a Database',
506
+ );
507
+ }
508
+
509
+ // -------------------------------------------------------------------------
510
+ // Tab navigation
511
+ // -------------------------------------------------------------------------
512
+
513
+ setSelectedTab(tab: DATABASE_EDITOR_TAB): void {
514
+ this.selectedTab = tab;
515
+ if (tab === DATABASE_EDITOR_TAB.GRAMMAR) {
516
+ // Lazily regenerate so the grammar preview always matches the current
517
+ // metamodel state. Errors are swallowed — the flow itself writes a
518
+ // diagnostic comment into `textContent` on failure.
519
+ flowResult(this.generateGrammarText()).catch(noop());
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Generate the Pure grammar for the underlying Database element and store
525
+ * it in `textContent`. Mirrors `generateElementGrammar()` from the base
526
+ * class but is kept local so the consumer doesn't need to know about the
527
+ * inherited flow.
528
+ */
529
+ *generateGrammarText(): ReturnType<
530
+ DatabaseEditorState['generateElementGrammar']
531
+ > {
532
+ yield flowResult(this.generateElementGrammar());
533
+ }
534
+
535
+ /**
536
+ * Render every view column-mapping's relational operation as Pure code, in
537
+ * a single batched engine call.
538
+ *
539
+ * Strategy: rather than transform metamodel `RelationalOperationElement`
540
+ * instances by hand (the V1 transformer isn't a public export), we rely on
541
+ * `elementToEntity(database)` which serializes the Database to its V1 JSON
542
+ * form synchronously. The resulting `entity.content` already contains the
543
+ * raw operations under
544
+ * `schemas[].views[].columnMappings[].operation`
545
+ * — exactly the shape `relationalOperationElementToPureCode` expects. We
546
+ * walk that JSON, build a Map keyed by `<schema>.<view>.<column>`, and let
547
+ * the engine return the rendered Pure code.
548
+ *
549
+ * On any failure (network/server/serialization), the partial map (possibly
550
+ * empty) stays in place and consumers fall back to the placeholder.
551
+ */
552
+ *loadViewColumnFormulas(): GeneratorFn<void> {
553
+ // Skip the round-trip entirely if the database has no views.
554
+ const hasAnyView = this.database.schemas.some(
555
+ (schema) => schema.views.length > 0,
556
+ );
557
+ if (!hasAnyView) {
558
+ return;
559
+ }
560
+ this.isLoadingViewColumnFormulas = true;
561
+ try {
562
+ const entity =
563
+ this.editorStore.graphManagerState.graphManager.elementToEntity(
564
+ this.database,
565
+ { pruneSourceInformation: true },
566
+ );
567
+ const operations = collectRawViewColumnOperations(entity.content);
568
+ if (operations.size === 0) {
569
+ return;
570
+ }
571
+ const rendered =
572
+ (yield this.editorStore.graphManagerState.graphManager.relationalOperationElementToPureCode(
573
+ operations,
574
+ )) as Map<string, string>;
575
+ // Replace the whole map atomically so consumers (which read it as a
576
+ // computed dependency) re-render once.
577
+ this.viewColumnFormulas = new Map(rendered);
578
+ } catch (error) {
579
+ assertErrorThrown(error);
580
+ this.editorStore.applicationStore.logService.error(
581
+ LogEvent.create(GRAPH_MANAGER_EVENT.PARSING_FAILURE),
582
+ `Couldn't render view-column formulas for database ${this.database.path}`,
583
+ error,
584
+ );
585
+ } finally {
586
+ this.isLoadingViewColumnFormulas = false;
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Render every database-level filter's relational operation as Pure code, in
592
+ * a single batched engine call.
593
+ *
594
+ * Same strategy as `loadViewColumnFormulas`: serialize the database to its
595
+ * V1 JSON via `elementToEntity` and walk the `filters[]` array on the root
596
+ * for `{ name, operation }` pairs. Filter names are unique within a
597
+ * Database, so the lookup key is just the filter name.
598
+ *
599
+ * On any failure (network/server/serialization), the partial map (possibly
600
+ * empty) stays in place and consumers fall back to the placeholder.
601
+ */
602
+ *loadFilterFormulas(): GeneratorFn<void> {
603
+ // Skip the round-trip entirely if the database has no filters.
604
+ if (this.database.filters.length === 0) {
605
+ return;
606
+ }
607
+ this.isLoadingFilterFormulas = true;
608
+ try {
609
+ const entity =
610
+ this.editorStore.graphManagerState.graphManager.elementToEntity(
611
+ this.database,
612
+ { pruneSourceInformation: true },
613
+ );
614
+ const operations = collectRawFilterOperations(entity.content);
615
+ if (operations.size === 0) {
616
+ return;
617
+ }
618
+ const rendered =
619
+ (yield this.editorStore.graphManagerState.graphManager.relationalOperationElementToPureCode(
620
+ operations,
621
+ )) as Map<string, string>;
622
+ // Replace the whole map atomically so consumers (which read it as a
623
+ // computed dependency) re-render once.
624
+ this.filterFormulas = new Map(rendered);
625
+ } catch (error) {
626
+ assertErrorThrown(error);
627
+ this.editorStore.applicationStore.logService.error(
628
+ LogEvent.create(GRAPH_MANAGER_EVENT.PARSING_FAILURE),
629
+ `Couldn't render filter formulas for database ${this.database.path}`,
630
+ error,
631
+ );
632
+ } finally {
633
+ this.isLoadingFilterFormulas = false;
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Render every join's relational operation as Pure code, in a single
639
+ * batched engine call. Same strategy as `loadFilterFormulas`: serialize
640
+ * the database to its V1 JSON via `elementToEntity` and walk the
641
+ * `joins[]` array on the root for `{ name, operation }` pairs. Join
642
+ * names are unique within a Database, so the lookup key is just the
643
+ * join name.
644
+ *
645
+ * On any failure (network/server/serialization), the partial map
646
+ * (possibly empty) stays in place and consumers fall back to the
647
+ * placeholder.
648
+ */
649
+ *loadJoinFormulas(): GeneratorFn<void> {
650
+ if (this.database.joins.length === 0) {
651
+ return;
652
+ }
653
+ this.isLoadingJoinFormulas = true;
654
+ try {
655
+ const entity =
656
+ this.editorStore.graphManagerState.graphManager.elementToEntity(
657
+ this.database,
658
+ { pruneSourceInformation: true },
659
+ );
660
+ const operations = collectRawJoinOperations(entity.content);
661
+ if (operations.size === 0) {
662
+ return;
663
+ }
664
+ const rendered =
665
+ (yield this.editorStore.graphManagerState.graphManager.relationalOperationElementToPureCode(
666
+ operations,
667
+ )) as Map<string, string>;
668
+ this.joinFormulas = new Map(rendered);
669
+ } catch (error) {
670
+ assertErrorThrown(error);
671
+ this.editorStore.applicationStore.logService.error(
672
+ LogEvent.create(GRAPH_MANAGER_EVENT.PARSING_FAILURE),
673
+ `Couldn't render join formulas for database ${this.database.path}`,
674
+ error,
675
+ );
676
+ } finally {
677
+ this.isLoadingJoinFormulas = false;
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Render every view's groupBy column expressions as Pure code, in a
683
+ * single batched engine call. Same strategy as `loadViewColumnFormulas`:
684
+ * serialize the database to its V1 JSON via `elementToEntity` and walk
685
+ * the `schemas[].views[].groupBy.columns[]` arrays for raw operations.
686
+ *
687
+ * Skips the round-trip entirely when no view declares a groupBy. On any
688
+ * failure the partial map (possibly empty) stays in place and consumers
689
+ * fall back to the static placeholder.
690
+ */
691
+ *loadViewGroupByFormulas(): GeneratorFn<void> {
692
+ const hasAnyGroupBy = this.database.schemas.some((schema) =>
693
+ schema.views.some((view) => Boolean(view.groupBy)),
694
+ );
695
+ if (!hasAnyGroupBy) {
696
+ return;
697
+ }
698
+ this.isLoadingViewGroupByFormulas = true;
699
+ try {
700
+ const entity =
701
+ this.editorStore.graphManagerState.graphManager.elementToEntity(
702
+ this.database,
703
+ { pruneSourceInformation: true },
704
+ );
705
+ const operations = collectRawViewGroupByOperations(entity.content);
706
+ if (operations.size === 0) {
707
+ return;
708
+ }
709
+ const rendered =
710
+ (yield this.editorStore.graphManagerState.graphManager.relationalOperationElementToPureCode(
711
+ operations,
712
+ )) as Map<string, string>;
713
+ this.viewGroupByFormulas = new Map(rendered);
714
+ } catch (error) {
715
+ assertErrorThrown(error);
716
+ this.editorStore.applicationStore.logService.error(
717
+ LogEvent.create(GRAPH_MANAGER_EVENT.PARSING_FAILURE),
718
+ `Couldn't render view-groupBy formulas for database ${this.database.path}`,
719
+ error,
720
+ );
721
+ } finally {
722
+ this.isLoadingViewGroupByFormulas = false;
723
+ }
724
+ }
725
+
726
+ // -------------------------------------------------------------------------
727
+ // Selection
728
+ // -------------------------------------------------------------------------
729
+
730
+ /**
731
+ * Plain relation selection — used when the user clicks directly on a node
732
+ * on the canvas. Doesn't trigger a pan because the user is already looking
733
+ * at the node they clicked. Clears any active join/filter selection
734
+ * (selecting a relation moves us out of those focus modes).
735
+ */
736
+ setSelectedRelation(relation: Table | View | undefined): void {
737
+ this.selectedRelation = relation;
738
+ this.selectedColumn = undefined;
739
+ this.selectedViewColumnName = undefined;
740
+ this.selectedJoin = undefined;
741
+ this.selectedFilter = undefined;
742
+ }
743
+
744
+ /**
745
+ * Side-panel relation click — selects the relation AND requests a pan so
746
+ * the canvas centers on it. Works identically for tables and views.
747
+ */
748
+ focusOnRelation(relation: Table | View): void {
749
+ this.selectedRelation = relation;
750
+ this.selectedColumn = undefined;
751
+ this.selectedViewColumnName = undefined;
752
+ this.selectedJoin = undefined;
753
+ this.selectedFilter = undefined;
754
+ this.panToSelectedRequestCounter++;
755
+ }
756
+
757
+ /**
758
+ * Side-panel column click — selects the parent table, the specific column,
759
+ * and requests a pan. Inside the table-node component this drives the
760
+ * single-row highlight. Only meaningful for tables; view column-mappings
761
+ * use `focusOnRelation` for now.
762
+ */
763
+ focusOnColumn(table: Table, column: Column): void {
764
+ this.selectedRelation = table;
765
+ this.selectedColumn = column;
766
+ this.selectedViewColumnName = undefined;
767
+ this.selectedJoin = undefined;
768
+ this.selectedFilter = undefined;
769
+ this.panToSelectedRequestCounter++;
770
+ }
771
+
772
+ /**
773
+ * Side-panel view column-mapping click \u2014 selects the parent view AND
774
+ * the specific column-mapping by name (views don't have `Column` refs).
775
+ * Drives the per-row highlight inside the view's canvas node, mirroring
776
+ * how `focusOnColumn` works for tables.
777
+ */
778
+ focusOnViewColumn(view: View, columnMappingName: string): void {
779
+ this.selectedRelation = view;
780
+ this.selectedColumn = undefined;
781
+ this.selectedViewColumnName = columnMappingName;
782
+ this.selectedJoin = undefined;
783
+ this.selectedFilter = undefined;
784
+ this.panToSelectedRequestCounter++;
785
+ }
786
+
787
+ /**
788
+ * Side-panel join click (or canvas edge click) — selects the join and
789
+ * requests a pan that fits BOTH endpoint relations in view. We clear the
790
+ * relation/column selection because join-mode is its own focus state with
791
+ * its own visual treatment (yellow rings on the two endpoints rather than
792
+ * a single blue ring on one).
793
+ */
794
+ focusOnJoin(join: Join): void {
795
+ this.selectedRelation = undefined;
796
+ this.selectedColumn = undefined;
797
+ this.selectedViewColumnName = undefined;
798
+ this.selectedJoin = join;
799
+ this.selectedFilter = undefined;
800
+ this.panToSelectedRequestCounter++;
801
+ }
802
+
803
+ /**
804
+ * Side-panel filter click — selects the filter. Filters don't have a
805
+ * canvas representation in the MVP (they live at the database level rather
806
+ * than tied to a specific table/edge), so this only highlights the row in
807
+ * the side panel; no pan is requested. Other selection axes are cleared so
808
+ * the highlight states stay mutually exclusive.
809
+ */
810
+ focusOnFilter(filter: Filter): void {
811
+ this.selectedRelation = undefined;
812
+ this.selectedColumn = undefined;
813
+ this.selectedViewColumnName = undefined;
814
+ this.selectedJoin = undefined;
815
+ this.selectedFilter = filter;
816
+ }
817
+
818
+ /**
819
+ * Clear all selections. Called from the canvas pane click handler so that
820
+ * clicking empty space deselects everything regardless of which selection
821
+ * axis is active.
822
+ */
823
+ clearSelection(): void {
824
+ this.selectedRelation = undefined;
825
+ this.selectedColumn = undefined;
826
+ this.selectedViewColumnName = undefined;
827
+ this.selectedJoin = undefined;
828
+ this.selectedFilter = undefined;
829
+ }
830
+
831
+ // -------------------------------------------------------------------------
832
+ // Expand/collapse
833
+ // -------------------------------------------------------------------------
834
+
835
+ toggleSchemaExpanded(id: string): void {
836
+ if (this.expandedSchemaIds.has(id)) {
837
+ this.expandedSchemaIds.delete(id);
838
+ } else {
839
+ this.expandedSchemaIds.add(id);
840
+ }
841
+ }
842
+
843
+ toggleRelationExpanded(id: string): void {
844
+ if (this.expandedRelationIds.has(id)) {
845
+ this.expandedRelationIds.delete(id);
846
+ } else {
847
+ this.expandedRelationIds.add(id);
848
+ }
849
+ }
850
+
851
+ expandAllSchemas(): void {
852
+ this.database.schemas.forEach((schema) => {
853
+ this.expandedSchemaIds.add(getSchemaNodeId(schema.name));
854
+ });
855
+ }
856
+
857
+ collapseAll(): void {
858
+ this.expandedSchemaIds.clear();
859
+ this.expandedRelationIds.clear();
860
+ }
861
+
862
+ /**
863
+ * Set the side panel's collapsed state directly. Useful for syncing the
864
+ * state when the user drags the splitter all the way down (i.e., the
865
+ * panel sets itself to collapsed in response to a resize event).
866
+ */
867
+ setSidePanelCollapsed(collapsed: boolean): void {
868
+ this.isSidePanelCollapsed = collapsed;
869
+ }
870
+
871
+ /**
872
+ * Flip the side panel's collapsed state. Driven by the explicit toggle
873
+ * button in the panel header (and the chevron rendered when collapsed).
874
+ */
875
+ toggleSidePanelCollapsed(): void {
876
+ this.isSidePanelCollapsed = !this.isSidePanelCollapsed;
877
+ }
878
+
879
+ /**
880
+ * Flip the editor's local theme between dark (the Studio default) and
881
+ * light. Scoped to this editor only — the rest of Studio remains in its
882
+ * configured theme. The toggle is exposed via a button in the editor's
883
+ * tab header. The new value is persisted to `UserDataService` so the
884
+ * choice survives reloads.
885
+ */
886
+ toggleTheme(): void {
887
+ this.theme = this.theme === 'dark' ? 'light' : 'dark';
888
+ LegendStudioUserDataHelper.databaseEditor_setTheme(
889
+ this.editorStore.applicationStore.userDataService,
890
+ this.theme,
891
+ );
892
+ }
893
+
894
+ /**
895
+ * Set the tree search/filter text. Empty string means \u201cno filter\u201d.
896
+ * The schema-tree consumer derives a lowercase form per render rather
897
+ * than computing it here so identical successive sets stay cheap.
898
+ */
899
+ setSearchText(text: string): void {
900
+ this.searchText = text;
901
+ }
902
+
903
+ /**
904
+ * Bump the fit-all counter. The canvas observes this and runs
905
+ * `fitView()` over the full graph (no `nodes` filter).
906
+ */
907
+ requestFitAll(): void {
908
+ this.fitAllRequestCounter++;
909
+ }
910
+
911
+ /**
912
+ * Bump the reset-layout counter. The canvas observes this and re-runs
913
+ * dagre over the current nodes/edges, undoing any user-initiated drags.
914
+ */
915
+ requestResetLayout(): void {
916
+ this.resetLayoutRequestCounter++;
917
+ }
918
+
919
+ override reprocess(
920
+ newElement: Database,
921
+ editorStore: EditorStore,
922
+ ): DatabaseEditorState {
923
+ const next = new DatabaseEditorState(editorStore, newElement);
924
+ // Preserve UX state across recompiles — a recompile shouldn't snap users
925
+ // back to the View tab or collapse everything.
926
+ next.selectedTab = this.selectedTab;
927
+ next.expandedSchemaIds = new Set(this.expandedSchemaIds);
928
+ next.expandedRelationIds = new Set(this.expandedRelationIds);
929
+ next.isSidePanelCollapsed = this.isSidePanelCollapsed;
930
+ next.theme = this.theme;
931
+ // Note: `viewColumnFormulas` and `filterFormulas` deliberately do NOT
932
+ // carry over. They're derived from operations on the new element and may
933
+ // have changed; the constructor's `loadViewColumnFormulas()` and
934
+ // `loadFilterFormulas()` kickoffs will refresh them. Until those resolve
935
+ // the placeholder shows briefly.
936
+ return next;
937
+ }
938
+ }