diff --git a/dbml-playground/package.json b/dbml-playground/package.json index f31c602c2..d49f8444a 100644 --- a/dbml-playground/package.json +++ b/dbml-playground/package.json @@ -1,6 +1,6 @@ { "name": "@dbml/playground", - "version": "10.1.0", + "version": "10.1.1-alpha.0", "description": "Interactive playground for debugging and visualizing the DBML parser pipeline", "author": "Holistics ", "license": "Apache-2.0", @@ -26,8 +26,8 @@ "format": "prettier --write src/" }, "dependencies": { - "@dbml/core": "^10.1.0", - "@dbml/parse": "^10.1.0", + "@dbml/core": "^10.1.1-alpha.0", + "@dbml/parse": "^10.1.1-alpha.0", "@phosphor-icons/vue": "^2.2.0", "dompurify": "^3.4.13", "floating-vue": "^5.2.2", diff --git a/lerna.json b/lerna.json index 009955d9f..050cefc36 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "10.1.0", + "version": "10.1.1-alpha.0", "npmClient": "yarn", "$schema": "node_modules/lerna/schemas/lerna-schema.json" } diff --git a/packages/dbml-cli/package.json b/packages/dbml-cli/package.json index 8d78df541..e4c4f587e 100644 --- a/packages/dbml-cli/package.json +++ b/packages/dbml-cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package", "name": "@dbml/cli", - "version": "10.1.0", + "version": "10.1.1-alpha.0", "description": "", "main": "lib/index.js", "license": "Apache-2.0", @@ -33,9 +33,9 @@ ], "dependencies": { "@babel/cli": "^7.21.0", - "@dbml/connector": "^10.1.0", - "@dbml/core": "^10.1.0", - "@dbml/parse": "^10.1.0", + "@dbml/connector": "^10.1.1-alpha.0", + "@dbml/core": "^10.1.1-alpha.0", + "@dbml/parse": "^10.1.1-alpha.0", "bluebird": "^3.5.5", "chalk": "^2.4.2", "commander": "^2.20.0", diff --git a/packages/dbml-connector/package.json b/packages/dbml-connector/package.json index 6eb0c8bc5..f0726b741 100644 --- a/packages/dbml-connector/package.json +++ b/packages/dbml-connector/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package", "name": "@dbml/connector", - "version": "10.1.0", + "version": "10.1.1-alpha.0", "description": "This package was created to fetch the schema JSON from many kind of databases.", "author": "huy.phung.sw@gmail.com", "license": "MIT", diff --git a/packages/dbml-core/package.json b/packages/dbml-core/package.json index 9d1282eed..ee8cfdf97 100644 --- a/packages/dbml-core/package.json +++ b/packages/dbml-core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package", "name": "@dbml/core", - "version": "10.1.0", + "version": "10.1.1-alpha.0", "description": "> TODO: description", "author": "Holistics ", "license": "Apache-2.0", @@ -47,7 +47,7 @@ "lint:fix": "eslint --fix ." }, "dependencies": { - "@dbml/parse": "^10.1.0", + "@dbml/parse": "^10.1.1-alpha.0", "antlr4": "^4.13.1", "lodash": "^4.18.1", "lodash-es": "^4.18.1", diff --git a/packages/dbml-core/src/index.ts b/packages/dbml-core/src/index.ts index 74a2e0ca2..f35c447f9 100644 --- a/packages/dbml-core/src/index.ts +++ b/packages/dbml-core/src/index.ts @@ -10,7 +10,6 @@ import { syncDiagramView, findDiagramViewBlocks, syncDep, - findDepBlocks, } from './transform'; import { VERSION } from './utils/version'; @@ -23,7 +22,6 @@ export { syncDiagramView, findDiagramViewBlocks, syncDep, - findDepBlocks, ModelExporter, CompilerError, Parser, @@ -80,7 +78,6 @@ export type { DepSyncOperation, DepSyncEdge, DepEndpointRef, - DepBlock, TextEdit, ElementIdentifier, SchemaIdentifier, diff --git a/packages/dbml-core/src/transform/index.ts b/packages/dbml-core/src/transform/index.ts index 2d95dea74..a47ae7118 100644 --- a/packages/dbml-core/src/transform/index.ts +++ b/packages/dbml-core/src/transform/index.ts @@ -1,10 +1,10 @@ import { Compiler, DEFAULT_ENTRY, MemoryProjectLayout } from '@dbml/parse'; import type { DiagramViewSyncOperation, DiagramViewBlock, TextEdit, - DepSyncOperation, DepBlock, ElementIdentifier, TableIdentifier, + DepSyncOperation, ElementIdentifier, TableIdentifier, } from '@dbml/parse'; -export { findDiagramViewBlocks, findDepBlocks } from '@dbml/parse'; +export { findDiagramViewBlocks } from '@dbml/parse'; export function renameTable (oldName: string | TableIdentifier, newName: string | TableIdentifier, dbmlCode: string): string { const layout = new MemoryProjectLayout(); @@ -28,12 +28,11 @@ export function syncDiagramView ( export function syncDep ( dbmlCode: string, operations: DepSyncOperation[], - blocks?: DepBlock[], ): { newDbml: string; edits: TextEdit[] } { const layout = new MemoryProjectLayout(); layout.setSource(DEFAULT_ENTRY, dbmlCode); const compiler = new Compiler(layout); - return compiler.syncDep(DEFAULT_ENTRY, operations, blocks); + return compiler.syncDep(DEFAULT_ENTRY, operations); } export function updateElementSettingEdit ( diff --git a/packages/dbml-core/types/index.d.ts b/packages/dbml-core/types/index.d.ts index b73db3be8..a2d8cd758 100644 --- a/packages/dbml-core/types/index.d.ts +++ b/packages/dbml-core/types/index.d.ts @@ -9,7 +9,6 @@ import { syncDiagramView, findDiagramViewBlocks, syncDep, - findDepBlocks, } from './transform'; export { renameTable, @@ -18,7 +17,6 @@ export { syncDiagramView, findDiagramViewBlocks, syncDep, - findDepBlocks, importer, exporter, ModelExporter, @@ -78,7 +76,6 @@ export type { DepSyncOperation, DepSyncEdge, DepEndpointRef, - DepBlock, TextEdit, ElementIdentifier, SchemaIdentifier, diff --git a/packages/dbml-core/types/transform/index.d.ts b/packages/dbml-core/types/transform/index.d.ts index c271ac6c9..2dae35401 100644 --- a/packages/dbml-core/types/transform/index.d.ts +++ b/packages/dbml-core/types/transform/index.d.ts @@ -1,6 +1,6 @@ import type { DiagramViewSyncOperation, DiagramViewBlock, TextEdit, - DepSyncOperation, DepBlock, ElementIdentifier, TableIdentifier, + DepSyncOperation, ElementIdentifier, TableIdentifier, } from '@dbml/parse'; export function renameTable( @@ -36,9 +36,4 @@ export function findDiagramViewBlocks( export function syncDep( dbmlCode: string, operations: DepSyncOperation[], - blocks?: DepBlock[], ): { newDbml: string; edits: TextEdit[] }; - -export function findDepBlocks( - dbmlCode: string, -): DepBlock[]; diff --git a/packages/dbml-parse/__tests__/examples/compiler/syncDep.test.ts b/packages/dbml-parse/__tests__/examples/compiler/syncDep.test.ts index 603d143e9..c71a55371 100644 --- a/packages/dbml-parse/__tests__/examples/compiler/syncDep.test.ts +++ b/packages/dbml-parse/__tests__/examples/compiler/syncDep.test.ts @@ -127,25 +127,21 @@ Dep { expect(db.deps[0].color).toBe('#abcabc'); }); - it('matches a table-level edge to a column-level block (implicit table dep)', () => { + it('leaves a column-level block alone when the target is table-level', () => { const dbml = `${PRELUDE} Dep { a.id -> b.id }`; - const { newDbml } = syncDep(dbml, [ + const { newDbml, edits } = syncDep(dbml, [ { operation: 'update', edge: tableEdge('a', 'b'), color: '#1abc9c' }, ]); - expect(newDbml).toContain('[color: #1abc9c]'); - // Should update in place, not create a duplicate - expect(newDbml.match(/Dep/g)?.length).toBe(1); - const db = interpret(newDbml).getValue()!; - expect(db.deps).toHaveLength(1); - expect(db.deps[0].color).toBe('#1abc9c'); + expect(edits).toHaveLength(0); + expect(newDbml).toBe(dbml); }); }); -describe('syncDep - create with table-level edge matching column-level block', () => { - it('treats an existing column-level block as an update when creating with a table-level edge', () => { +describe('syncDep - create with a table-level edge beside a column-level block', () => { + it('creates the direct block rather than colouring the column-level one', () => { const dbml = `${PRELUDE} Dep { a.id -> b.id @@ -153,12 +149,89 @@ Dep { const { newDbml } = syncDep(dbml, [ { operation: 'create', edge: tableEdge('a', 'b'), color: '#ff5733' }, ]); - // Should update existing block, not create a new one - expect(newDbml.match(/Dep/g)?.length).toBe(1); - expect(newDbml).toContain('[color: #ff5733]'); + expect(newDbml).toContain('Dep [color: #ff5733] {'); + expect(newDbml).toContain('a -> b'); + const db = interpret(newDbml).getValue()!; + expect(db.deps).toHaveLength(2); + const direct = db.deps.find((d) => d.edges[0].upstream.fieldNames.length === 0); + const columnLevel = db.deps.find((d) => d.edges[0].upstream.fieldNames.length === 1); + expect(direct?.color).toBe('#ff5733'); + expect(columnLevel?.color).toBeUndefined(); + }); +}); + +describe('syncDep - mixed-level edges', () => { + const mixedEdge = (): DepSyncOperation['edge'] => ({ + upstream: { tableName: 'a', fieldNames: ['id'] }, + downstream: { tableName: 'b', fieldNames: [] }, + }); + + const MIXED = `${PRELUDE} +Dep { + a.id -> b +}`; + + it('colors the block carrying the edge', () => { + const { newDbml } = syncDep(MIXED, [ + { operation: 'create', edge: mixedEdge(), color: '#0055ff' }, + ]); + const db = interpret(newDbml).getValue()!; + expect(db.deps).toHaveLength(1); + expect(db.deps[0].color).toBe('#0055ff'); + }); + + it('notes the block carrying the edge', () => { + const { newDbml } = syncDep(MIXED, [ + { operation: 'update', edge: mixedEdge(), note: 'from etl' }, + ]); const db = interpret(newDbml).getValue()!; expect(db.deps).toHaveLength(1); - expect(db.deps[0].color).toBe('#ff5733'); + expect(db.deps[0].note?.value).toBe('from etl'); + }); + + it('leaves one block after repeated picks', () => { + const first = syncDep(MIXED, [ + { operation: 'create', edge: mixedEdge(), color: '#0055ff' }, + ]).newDbml; + const second = syncDep(first, [ + { operation: 'create', edge: mixedEdge(), color: '#ff0055' }, + ]).newDbml; + const db = interpret(second).getValue()!; + expect(db.deps).toHaveLength(1); + expect(db.deps[0].color).toBe('#ff0055'); + }); + + it('tells a schema-qualified table apart from a table column', () => { + const dbml = ` +Table s.a { id int } +Table a { id int } +Table b { id int } + +Dep { + s.a -> b +} + +Dep { + a.id -> b +}`; + const { newDbml } = syncDep(dbml, [ + { + operation: 'create', + edge: { upstream: { schemaName: 's', tableName: 'a', fieldNames: [] }, downstream: { tableName: 'b', fieldNames: [] } }, + color: '#111111', + }, + { + operation: 'create', + edge: mixedEdge(), + color: '#222222', + }, + ]); + const db = interpret(newDbml).getValue()!; + expect(db.deps).toHaveLength(2); + const schemaLevel = db.deps.find((d) => d.edges[0].upstream.fieldNames.length === 0); + const columnLevel = db.deps.find((d) => d.edges[0].upstream.fieldNames.length === 1); + expect(schemaLevel?.color).toBe('#111111'); + expect(columnLevel?.color).toBe('#222222'); }); }); diff --git a/packages/dbml-parse/__tests__/examples/compiler/updateElementSetting.test.ts b/packages/dbml-parse/__tests__/examples/compiler/updateElementSetting.test.ts index 8e6d68d27..f703101e9 100644 --- a/packages/dbml-parse/__tests__/examples/compiler/updateElementSetting.test.ts +++ b/packages/dbml-parse/__tests__/examples/compiler/updateElementSetting.test.ts @@ -289,33 +289,38 @@ Table b { }); describe('updateElementSetting - inline dep', () => { - const dep = (up: string, down: string): ElementIdentifier => ({ + // An inline `[dep: -> b.id]` on a.id is the edge a.id -> b.id, so its identity carries both fields + const dep = (upTable: string, upField: string, downTable: string, downField: string): ElementIdentifier => ({ kind: MetadataKind.Dep, - upstream: { tableName: up }, - downstream: { tableName: down }, + upstream: { tableName: upTable, fieldNames: [upField] }, + downstream: { tableName: downTable, fieldNames: [downField] }, }); - it('extracts inline dep to standalone when adding a setting', () => { - const dbml = `Table a { - id int [dep: -> b] + const INLINE = `Table a { + id int [dep: -> b.id] } Table b { id int }`; - const result = update(dbml, dep('a', 'b'), 'color', '#FF0000'); - expect(result).not.toContain('[dep: -> b]'); + + it('extracts inline dep to standalone when adding a setting', () => { + const result = update(INLINE, dep('a', 'id', 'b', 'id'), 'color', '#FF0000'); + expect(result).not.toContain('[dep: -> b.id]'); expect(result).toContain('color: #FF0000'); - expect(result).toContain('a -> b'); + expect(result).toContain('a.id -> b.id'); }); it('does nothing when removing a setting from an inline dep', () => { - const dbml = `Table a { - id int [dep: -> b] -} -Table b { - id int -}`; - const result = update(dbml, dep('a', 'b'), 'color', null); - expect(result).toBe(dbml); + const result = update(INLINE, dep('a', 'id', 'b', 'id'), 'color', null); + expect(result).toBe(INLINE); + }); + + it('does nothing when the target is table-level and the inline dep is not', () => { + const result = update(INLINE, { + kind: MetadataKind.Dep, + upstream: { tableName: 'a' }, + downstream: { tableName: 'b' }, + } as ElementIdentifier, 'color', '#FF0000'); + expect(result).toBe(INLINE); }); }); diff --git a/packages/dbml-parse/package.json b/packages/dbml-parse/package.json index 59aa5db3a..6b17122b9 100644 --- a/packages/dbml-parse/package.json +++ b/packages/dbml-parse/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package", "name": "@dbml/parse", - "version": "10.1.0", + "version": "10.1.1-alpha.0", "description": "DBML parser v2", "author": "Holistics ", "license": "Apache-2.0", diff --git a/packages/dbml-parse/src/compiler/queries/transform/index.ts b/packages/dbml-parse/src/compiler/queries/transform/index.ts index 6d726fc4d..3eb086342 100644 --- a/packages/dbml-parse/src/compiler/queries/transform/index.ts +++ b/packages/dbml-parse/src/compiler/queries/transform/index.ts @@ -8,12 +8,10 @@ export { } from './syncDiagramView'; export { syncDep, - findDepBlocks, generateDepBlock, type DepSyncOperation, type DepSyncEdge, type DepEndpointRef, - type DepBlock, } from './syncDep'; export { applyTextEdits, type TextEdit } from './applyTextEdits'; export type { diff --git a/packages/dbml-parse/src/compiler/queries/transform/syncDep.ts b/packages/dbml-parse/src/compiler/queries/transform/syncDep.ts index ff1cd3b21..1166b2d8d 100644 --- a/packages/dbml-parse/src/compiler/queries/transform/syncDep.ts +++ b/packages/dbml-parse/src/compiler/queries/transform/syncDep.ts @@ -1,40 +1,28 @@ /** * Dep block transform - read and write `Dep` blocks in DBML source. * - * Parallel to {@link ./syncDiagramView.ts}, but a Dep block has no name to - * key on - a block is identified by its edge endpoints (upstream/downstream - * schema + table + fields). The color picker uses this to write a dep's + * Parallel to {@link ./syncDiagramView.ts}, but a Dep block has no name to key on, + * so a write is aimed at an edge and {@link findDepDefinition} finds the block or the + * inline `[dep: …]` setting carrying it. The color picker uses this to write a dep's * `[color]`: * - `update` an existing block's `[color]`, or * - `create` a direct `Dep { a -> b } [color: ]` when the picked - * (table-level) line has no backing block, or + * (table-level) line has no direct block, or * - `remove` an existing block's `[color]` so the dep falls back to its * upstream -> group -> grey default. * `create` treats an already-matching block as an `update`, so it never * produces a duplicate `Dep`. `remove` on an edge with no block is a no-op. */ -import { DEFAULT_ENTRY } from '@/constants'; -import Lexer from '@/core/lexer/lexer'; -import Parser from '@/core/parser/parser'; import type { Filepath } from '@/core/types/filepath'; -import { ElementKind, SettingName } from '@/core/types/keywords'; -import { DEP_DOWNSTREAM, DEP_UPSTREAM } from '@/core/types/schemaJson'; -import { - ElementDeclarationNode, - FunctionApplicationNode, - InfixExpressionNode, - ListExpressionNode, - PrefixExpressionNode, - ProgramNode, - SyntaxNodeIdGenerator, -} from '@/core/types/nodes'; -import { destructureComplexVariable, extractSettingName } from '@/core/utils/expression'; +import { SettingName } from '@/core/types/keywords'; +import { ElementDeclarationNode } from '@/core/types/nodes'; import type Compiler from '../../index'; -import { endpointMatches, formatEndpoint } from './utils'; +import { formatEndpoint, findDepDefinition } from './utils'; +import type { DepDefinition } from './utils'; import { TextEdit, applyTextEdits } from './applyTextEdits'; import { updateNoteEdit, removeNoteEdit, addNoteEdit } from '@/core/utils/note'; -import { updateSettingEdit, removeSettingEdit } from '@/core/utils/setting'; +import { updateSettingEdit } from '@/core/utils/setting'; // Types @@ -59,19 +47,6 @@ export interface DepSyncOperation { note?: string | null; } -export interface DepBlock { - startIndex: number; - endIndex: number; - declaration: ElementDeclarationNode; - edges: DepSyncEdge[]; -} - -export interface InlineDep { - edge: DepSyncEdge; - fullStart: number; - fullEnd: number; -} - /** * Synchronizes `Dep` blocks in the DBML source at `filepath`. * Applies create/update/remove operations and returns the rewritten source. @@ -80,19 +55,15 @@ export function syncDep ( this: Compiler, filepath: Filepath, operations: DepSyncOperation[], - blocks?: DepBlock[], ): { newDbml: string; edits: TextEdit[]; } { const dbml = this.getSource(filepath) ?? ''; - const program = this.parseFile(filepath).getValue().ast; - const originalBlocks = blocks ?? depBlocksFromProgram(program); - const inlineDeps = inlineDepsFromProgram(dbml, program); const allEdits: TextEdit[] = []; for (const op of operations) { - allEdits.push(...applyOperation(dbml, op, originalBlocks, inlineDeps)); + allEdits.push(...applyOperation(this, filepath, dbml, op)); } allEdits.sort((a, b) => b.start - a.start); @@ -100,31 +71,6 @@ export function syncDep ( return { newDbml, edits: allEdits }; } -/** - * Returns every standalone `Dep` block in `source`. - * On lex/parse error returns [] (mirrors findDiagramViewBlocks). - */ -export function findDepBlocks (source: string): DepBlock[] { - const lexerResult = new Lexer(source, DEFAULT_ENTRY).lex(); - if (lexerResult.getErrors().length > 0) return []; - - const ast = new Parser(source, lexerResult.getValue(), new SyntaxNodeIdGenerator(), DEFAULT_ENTRY).parse(); - if (ast.getErrors().length > 0) return []; - - return depBlocksFromProgram(ast.getValue().ast); -} - -/** Returns every inline `[dep: -> target]` setting on columns in `source`. */ -export function findInlineDeps (source: string): InlineDep[] { - const lexerResult = new Lexer(source, DEFAULT_ENTRY).lex(); - if (lexerResult.getErrors().length > 0) return []; - - const ast = new Parser(source, lexerResult.getValue(), new SyntaxNodeIdGenerator(), DEFAULT_ENTRY).parse(); - if (ast.getErrors().length > 0) return []; - - return inlineDepsFromProgram(source, ast.getValue().ast); -} - /** Emit a `Dep` block string for one edge with a `[color]` setting. */ export function generateDepBlock (edge: DepSyncEdge, color: string): string { const up = formatEndpoint(edge.upstream); @@ -132,207 +78,55 @@ export function generateDepBlock (edge: DepSyncEdge, color: string): string { return `Dep [color: ${color}] {\n ${up} -> ${down}\n}`; } -/** Extract `DepBlock[]` from an already-parsed program AST. */ -export function depBlocksFromProgram (program: ProgramNode): DepBlock[] { - const blocks: DepBlock[] = []; - - for (const element of program.declarations) { - if (!(element instanceof ElementDeclarationNode) || !element.isKind(ElementKind.Dep)) continue; - - const edges: DepSyncEdge[] = []; - - const body = element.body; - if (body instanceof FunctionApplicationNode) { - if (body.callee instanceof InfixExpressionNode) { - const edge = edgeFromInfix(body.callee); - if (edge) edges.push(edge); - } - } else if (body) { - for (const field of body.body) { - if (field instanceof FunctionApplicationNode && field.callee instanceof InfixExpressionNode) { - const edge = edgeFromInfix(field.callee); - if (edge) edges.push(edge); - } - } - } - - blocks.push({ - startIndex: element.start, - endIndex: element.end, - declaration: element, - edges, - }); - } - - return blocks; -} - -/** Extract `InlineDep[]` from an already-parsed program AST. */ -export function inlineDepsFromProgram (source: string, program: ProgramNode): InlineDep[] { - const result: InlineDep[] = []; - - for (const element of program.declarations) { - if (!(element instanceof ElementDeclarationNode) || !element.isKind(ElementKind.Table)) continue; - const tableFragments = element.name ? destructureComplexVariable(element.name) ?? [] : []; - if (tableFragments.length === 0) continue; - - const tableName = tableFragments[tableFragments.length - 1]; - const schemaName = tableFragments.length > 1 ? tableFragments[tableFragments.length - 2] : undefined; - - const body = element.body; - if (!body || body instanceof FunctionApplicationNode) continue; - for (const field of body.body) { - if (!(field instanceof FunctionApplicationNode) || !field.callee) continue; - const columnName = destructureComplexVariable(field.callee)?.at(-1); - if (!columnName) continue; - - const host: DepEndpointRef = { - schemaName, - tableName, - fieldNames: [ - columnName, - ], - }; - const edit = removeSettingEdit(field, SettingName.Dep, source); - if (!edit) continue; - for (const dep of extractInlineDepEdges(field, host)) { - result.push({ edge: dep, fullStart: edit.start, fullEnd: edit.end }); - } - } - } - - return result; -} - -// Private helpers - -/** Parse an `a -> b` / `a <- b` infix node into a DepSyncEdge. */ -function edgeFromInfix (infix: InfixExpressionNode): DepSyncEdge | undefined { - const op = infix.op?.value; - if (op !== DEP_DOWNSTREAM && op !== DEP_UPSTREAM) return undefined; - const left = infix.leftExpression; - const right = infix.rightExpression; - if (!left || !right) return undefined; - - const upstreamNode = op === DEP_UPSTREAM ? right : left; - const downstreamNode = op === DEP_UPSTREAM ? left : right; - - const upFragments = destructureComplexVariable(upstreamNode); - const downFragments = destructureComplexVariable(downstreamNode); - if (!upFragments || !downFragments) return undefined; - - const hasFields = upFragments.length > 1 && downFragments.length > 1; - const upstream = fragmentsToEndpoint(upFragments, hasFields); - const downstream = fragmentsToEndpoint(downFragments, hasFields); - if (!upstream || !downstream) return undefined; - return { upstream, downstream }; -} - -/** - * Split complex-variable fragments into schema/table/fields. - * `hasFields` resolves the `a.b` ambiguity (schema.table vs table.field). - */ -function fragmentsToEndpoint (fragments: string[], hasFields: boolean): DepEndpointRef | undefined { - if (fragments.length === 0) return undefined; - if (!hasFields) { - const tableName = fragments[fragments.length - 1]; - const schemaName = fragments.length > 1 ? fragments[fragments.length - 2] : null; - return { schemaName, tableName, fieldNames: [] }; - } - const fieldNames = [ - fragments[fragments.length - 1], - ]; - const tableName = fragments[fragments.length - 2]; - const schemaName = fragments.length > 2 ? fragments[fragments.length - 3] : null; - return { schemaName, tableName, fieldNames }; -} - -/** Extract dep edges from inline `[dep: -> target]` settings on a column. */ -function extractInlineDepEdges (field: FunctionApplicationNode, host: DepEndpointRef): DepSyncEdge[] { - const list = field.args.find((a) => a instanceof ListExpressionNode) as ListExpressionNode | undefined; - if (!list) return []; - - const edges: DepSyncEdge[] = []; - for (const attr of list.elementList) { - if (extractSettingName(attr) !== SettingName.Dep) continue; - const value = attr.value; - if (!(value instanceof PrefixExpressionNode) || !value.expression) continue; - const dir = value.op?.value; - if (dir !== DEP_DOWNSTREAM && dir !== DEP_UPSTREAM) continue; - const targetFragments = destructureComplexVariable(value.expression); - if (!targetFragments) continue; - const target = fragmentsToEndpoint(targetFragments, targetFragments.length > 1); - if (!target) continue; - edges.push(dir === DEP_DOWNSTREAM - ? { upstream: host, downstream: target } - : { upstream: target, downstream: host }); - } - return edges; -} - -function edgesMatch (candidate: DepSyncEdge, target: DepSyncEdge): boolean { - return endpointMatches(candidate.upstream, target.upstream) && endpointMatches(candidate.downstream, target.downstream); -} - -function blockHasEdge (block: DepBlock, edge: DepSyncEdge): boolean { - return block.edges.some((e) => edgesMatch(e, edge)); -} - -function findBlockForEdge (blocks: DepBlock[], edge: DepSyncEdge): DepBlock | undefined { - return blocks.find((b) => blockHasEdge(b, edge)); -} - /** Dispatch a single sync operation to the appropriate edit strategy. */ -function applyOperation (dbml: string, operation: DepSyncOperation, blocks: DepBlock[], inlineDeps: InlineDep[]): TextEdit[] { +function applyOperation (compiler: Compiler, filepath: Filepath, dbml: string, operation: DepSyncOperation): TextEdit[] { + const definition = findDepDefinition(compiler, filepath, operation.edge); + switch (operation.operation) { case 'create': - return computeCreateEdit(dbml, operation, blocks, inlineDeps); - case 'update': { - const block = findBlockForEdge(blocks, operation.edge); - return block ? computeUpdateEdit(operation, block, dbml) : []; - } - case 'remove': { - const block = findBlockForEdge(blocks, operation.edge); - return block ? computeUpdateEdit({ ...operation, color: undefined, note: undefined }, block, dbml) : []; - } + return computeCreateEdit(dbml, operation, definition); + case 'update': + return definition?.kind === 'block' ? computeUpdateEdit(operation, definition.declaration, dbml) : []; + case 'remove': + return definition?.kind === 'block' + ? computeUpdateEdit({ ...operation, color: undefined, note: undefined }, definition.declaration, dbml) + : []; default: return []; } } /** Compute edits to update an existing block's color and/or note. */ -function computeUpdateEdit (operation: DepSyncOperation, block: DepBlock, source: string): TextEdit[] { +function computeUpdateEdit (operation: DepSyncOperation, declaration: ElementDeclarationNode, source: string): TextEdit[] { const edits: TextEdit[] = []; if (operation.color !== undefined) { - const edit = updateSettingEdit(block.declaration, SettingName.Color, operation.color, source); + const edit = updateSettingEdit(declaration, SettingName.Color, operation.color, source); if (edit) edits.push(edit); } if (operation.note === null) { - const edit = removeNoteEdit(block.declaration); + const edit = removeNoteEdit(declaration); if (edit) edits.push(edit); } else if (operation.note !== undefined) { - const edit = updateNoteEdit(block.declaration, operation.note) ?? addNoteEdit(block.declaration, operation.note); + const edit = updateNoteEdit(declaration, operation.note) ?? addNoteEdit(declaration, operation.note); if (edit) edits.push(edit); } return edits; } -/** Compute edits to create a new Dep block (or update if one already matches). */ -function computeCreateEdit (dbml: string, operation: DepSyncOperation, blocks: DepBlock[], inlineDeps: InlineDep[]): TextEdit[] { - const existing = findBlockForEdge(blocks, operation.edge); - if (existing) return computeUpdateEdit(operation, existing, dbml); +/** Compute edits to create a new Dep block (or update if one already carries the edge). */ +function computeCreateEdit (dbml: string, operation: DepSyncOperation, definition: DepDefinition | undefined): TextEdit[] { + if (definition?.kind === 'block') return computeUpdateEdit(operation, definition.declaration, dbml); const newBlock = generateDepBlock(operation.edge, operation.color ?? ''); const createEdit: TextEdit = { start: dbml.length, end: dbml.length, newText: '\n\n' + newBlock + '\n' }; // If the edge is authored inline, strip the inline setting to avoid duplication. - const inline = inlineDeps.find((d) => edgesMatch(d.edge, operation.edge)); - if (inline) { + if (definition?.kind === 'inline') { return [ - { start: inline.fullStart, end: inline.fullEnd, newText: '' }, + { start: definition.fullStart, end: definition.fullEnd, newText: '' }, createEdit, ]; } diff --git a/packages/dbml-parse/src/compiler/queries/transform/updateElementSetting.ts b/packages/dbml-parse/src/compiler/queries/transform/updateElementSetting.ts index 6c9da8615..cde93d6be 100644 --- a/packages/dbml-parse/src/compiler/queries/transform/updateElementSetting.ts +++ b/packages/dbml-parse/src/compiler/queries/transform/updateElementSetting.ts @@ -6,10 +6,9 @@ import { applyTextEdits, type TextEdit } from './applyTextEdits'; import { updateSettingEdit } from '@/core/utils/setting'; import type { ElementIdentifier, DepIdentifier, RefIdentifier } from './types'; import { - endpointsEqual, endpointMatches, lookupElementSymbol, - formatEndpoint, formatSetting, findRefDefinition, + lookupElementSymbol, + formatEndpoint, formatSetting, findRefDefinition, findDepDefinition, } from './utils'; -import { depBlocksFromProgram, inlineDepsFromProgram } from './syncDep'; import { FunctionApplicationNode, type SyntaxNode } from '@/core/types/nodes'; export function updateElementSettingEdit ( @@ -75,19 +74,12 @@ function findNamedElementDeclaration (compiler: Compiler, filepath: Filepath, ta function updateDepSettingEdit (compiler: Compiler, filepath: Filepath, target: DepIdentifier, settingName: string, value: string | null | undefined): TextEdit[] { const source = compiler.getSource(filepath) ?? ''; - const program = compiler.parseFile(filepath).getValue().ast; - const block = depBlocksFromProgram(program).find((b) => - b.edges.some((e) => endpointsEqual(e.upstream, target.upstream) && endpointsEqual(e.downstream, target.downstream)), - ); + const definition = findDepDefinition(compiler, filepath, target); + if (!definition) return []; - if (!block) { + if (definition.kind === 'inline') { if (value === null) return []; - const inline = inlineDepsFromProgram(source, program).find((d) => - endpointMatches(d.edge.upstream, target.upstream) && endpointMatches(d.edge.downstream, target.downstream), - ); - if (!inline) return []; - const up = formatEndpoint(target.upstream); const down = formatEndpoint(target.downstream); const setting = formatSetting(settingName, value); @@ -96,12 +88,12 @@ function updateDepSettingEdit (compiler: Compiler, filepath: Filepath, target: D ? `Dep [${setting}] {\n ${up} -> ${down}\n}` : `Dep {\n ${up} -> ${down}\n}`; return [ - { start: inline.fullStart, end: inline.fullEnd, newText: '' }, + { start: definition.fullStart, end: definition.fullEnd, newText: '' }, { start: source.length, end: source.length, newText: '\n\n' + depBlock + '\n' }, ]; } - const { declaration } = block; + const { declaration } = definition; const { body } = declaration; if (body instanceof FunctionApplicationNode) { diff --git a/packages/dbml-parse/src/compiler/queries/transform/utils/dep.ts b/packages/dbml-parse/src/compiler/queries/transform/utils/dep.ts new file mode 100644 index 000000000..2baa8562c --- /dev/null +++ b/packages/dbml-parse/src/compiler/queries/transform/utils/dep.ts @@ -0,0 +1,119 @@ +import { SettingName } from '@/core/types/keywords'; +import { ElementDeclarationNode, FunctionApplicationNode, PrefixExpressionNode } from '@/core/types/nodes'; +import { Filepath } from '@/core/types/filepath'; +import { UNHANDLED } from '@/core/types/module'; +import { removeSettingEdit } from '@/core/utils/setting'; +import { DepMetadata } from '@/core/types/symbol/metadata'; +import { SymbolKind } from '@/core/types/symbol'; +import type { NodeSymbol } from '@/core/types/symbol'; +import type Compiler from '../../../index'; +import type { DepIdentifier, EndpointRef } from '../types'; +import { lookupElementSymbol, normalizeSchema } from './index'; + +export interface InlineDepDefinition { + kind: 'inline'; + op: string; + fullStart: number; + fullEnd: number; +} + +export interface BlockDepDefinition { + kind: 'block'; + declaration: ElementDeclarationNode; +} + +export type DepDefinition = InlineDepDefinition | BlockDepDefinition; + +interface ResolvedEndpoint { + table?: NodeSymbol; + columns: (NodeSymbol | undefined)[]; +} + +function resolveEndpoint (compiler: Compiler, filepath: Filepath, endpoint: EndpointRef): ResolvedEndpoint { + const table = lookupElementSymbol( + compiler, + filepath, + normalizeSchema(endpoint.schemaName), + endpoint.tableName, + SymbolKind.Table, + ); + const columns = (endpoint.fieldNames ?? []).map((name) => ( + table ? compiler.lookupMembers(table, SymbolKind.Column, name) : undefined + )); + + return { table, columns }; +} + +// Aliases and imports give the same element more than one symbol; its declaration is single. +function sameSymbol (left: NodeSymbol | undefined, right: NodeSymbol | undefined): boolean { + const leftDeclaration = left?.originalSymbol.declaration; + return !!leftDeclaration && leftDeclaration === right?.originalSymbol.declaration; +} + +function endpointMatches ( + table: NodeSymbol | undefined, + columns: NodeSymbol[], + target: ResolvedEndpoint, +): boolean { + if (!sameSymbol(table, target.table)) return false; + if (columns.length !== target.columns.length) return false; + return columns.every((column, i) => sameSymbol(column, target.columns[i])); +} + +/** + * Finds the `Dep` block or inline `[dep: …]` setting that carries the edge `target` names. + * + * Matching runs on resolved table and column symbols, the way {@link findRefDefinition} does. + * The endpoints' source text cannot be compared instead: `a.b` is `schema.table` or + * `table.field` depending on what `a` resolves to, so a mixed-level edge such as + * `a.x -> b` is not describable without the symbol table. + * + * A target with no field names matches only an edge with no columns on that side. + */ +export function findDepDefinition ( + compiler: Compiler, + filepath: Filepath, + target: DepIdentifier, +): DepDefinition | undefined { + const ast = compiler.parseFile(filepath).getValue().ast; + const programSymbol = compiler.nodeSymbol(ast).getFiltered(UNHANDLED); + if (!programSymbol) return undefined; + + const upstream = resolveEndpoint(compiler, filepath, target.upstream); + const downstream = resolveEndpoint(compiler, filepath, target.downstream); + if (!upstream.table || !downstream.table) return undefined; + + const source = compiler.getSource(filepath) ?? ''; + + for (const meta of compiler.symbolMetadata(programSymbol)) { + if (!(meta instanceof DepMetadata)) continue; + + const upstreamTables = meta.upstreamTables(compiler); + const upstreamColumns = meta.upstreamColumns(compiler); + const downstreamTables = meta.downstreamTables(compiler); + const downstreamColumns = meta.downstreamColumns(compiler); + + const carriesEdge = upstreamTables.some((_, i) => ( + endpointMatches(upstreamTables[i], upstreamColumns[i] ?? [], upstream) + && endpointMatches(downstreamTables[i], downstreamColumns[i] ?? [], downstream) + )); + if (!carriesEdge) continue; + + if (meta.declaration instanceof ElementDeclarationNode) { + return { kind: 'block', declaration: meta.declaration }; + } + + const columnField = meta.declaration.parentOfKind(FunctionApplicationNode); + if (!columnField) continue; + const fullEdit = removeSettingEdit(columnField, SettingName.Dep, source); + if (!fullEdit) continue; + const prefix = meta.declaration.value; + if (!(prefix instanceof PrefixExpressionNode)) continue; + const op = prefix.op?.value; + if (!op) continue; + + return { kind: 'inline', op, fullStart: fullEdit.start, fullEnd: fullEdit.end }; + } + + return undefined; +} diff --git a/packages/dbml-parse/src/compiler/queries/transform/utils/index.ts b/packages/dbml-parse/src/compiler/queries/transform/utils/index.ts index 2624c00c4..779a94a64 100644 --- a/packages/dbml-parse/src/compiler/queries/transform/utils/index.ts +++ b/packages/dbml-parse/src/compiler/queries/transform/utils/index.ts @@ -7,34 +7,17 @@ import { addDoubleQuoteIfNeeded, splitQualifiedIdentifier } from '../../utils'; import type { EndpointRef } from '../types'; export type { ElementIdentifier } from '../types'; export { findRefDefinition, type InlineRef, type StandaloneRef } from './ref'; +export { + findDepDefinition, + type DepDefinition, + type InlineDepDefinition, + type BlockDepDefinition, +} from './dep'; export function normalizeSchema (schema?: string | null): string { return schema && schema.length > 0 ? schema : DEFAULT_SCHEMA_NAME; } -export function endpointsEqual (left: EndpointRef, right: EndpointRef): boolean { - if (normalizeSchema(left.schemaName) !== normalizeSchema(right.schemaName)) - return false; - if (left.tableName !== right.tableName) return false; - const leftFieldNames = left.fieldNames ?? []; - const rightFieldNames = right.fieldNames ?? []; - if (leftFieldNames.length !== rightFieldNames.length) return false; - return leftFieldNames.every((leftField, i) => leftField === rightFieldNames[i]); -} - -export function endpointMatches ( - candidate: EndpointRef, - target: EndpointRef, -): boolean { - if (normalizeSchema(candidate.schemaName) !== normalizeSchema(target.schemaName)) return false; - if (candidate.tableName !== target.tableName) return false; - const targetFieldNames = target.fieldNames ?? []; - if (targetFieldNames.length === 0) return true; - const candidateFieldNames = candidate.fieldNames ?? []; - if (candidateFieldNames.length !== targetFieldNames.length) return false; - return candidateFieldNames.every((f, i) => f === targetFieldNames[i]); -} - export function normalizeTableName ( input: string | { schema?: string; table: string }, ): { diff --git a/packages/dbml-parse/src/index.ts b/packages/dbml-parse/src/index.ts index 2e9c9e372..ca5e50b49 100644 --- a/packages/dbml-parse/src/index.ts +++ b/packages/dbml-parse/src/index.ts @@ -115,11 +115,11 @@ export type { TextEdit, } from '@/compiler/queries/transform'; -export { findDiagramViewBlocks, findDepBlocks } from '@/compiler/queries/transform'; +export { findDiagramViewBlocks } from '@/compiler/queries/transform'; // Dep transform types export type { - DepSyncOperation, DepSyncEdge, DepEndpointRef, DepBlock, + DepSyncOperation, DepSyncEdge, DepEndpointRef, } from '@/compiler/queries/transform'; // Element identifier types