diff --git a/dbml-homepage/docs/syntax/enrichment-visualization.md b/dbml-homepage/docs/syntax/enrichment-visualization.md index 4f2540393..f6ead6cb9 100644 --- a/dbml-homepage/docs/syntax/enrichment-visualization.md +++ b/dbml-homepage/docs/syntax/enrichment-visualization.md @@ -18,6 +18,7 @@ This part covers features specific to diagram & wiki tools like [dbdiagram.io](h - [TableGroup Settings](#tablegroup-settings) - [DiagramView](#diagramview) - [Colors](#colors) +- [Inactive Ref](#inactive-ref) ## Note Definition @@ -255,3 +256,22 @@ TableGroup e_commerce [color: #3498DB] { countries } ``` + +## Inactive Ref + +Use `inactive` on a relationship to mark it as inactive. Inactive refs are displayed as a dotted line in the diagram, allowing you to document relationships that exist logically but are not actively enforced. + +```text +// short form +Ref: posts.user_id > users.id [inactive] + +// with other settings +Ref: posts.user_id > users.id [delete: cascade, inactive] + +// long form +Ref { + posts.user_id > users.id [inactive] +} +``` + +The `inactive` setting takes no value. diff --git a/packages/dbml-core/__tests__/examples/exporter/exporter.spec.ts b/packages/dbml-core/__tests__/examples/exporter/exporter.spec.ts index a0e476d0f..f2b5d5094 100644 --- a/packages/dbml-core/__tests__/examples/exporter/exporter.spec.ts +++ b/packages/dbml-core/__tests__/examples/exporter/exporter.spec.ts @@ -59,6 +59,38 @@ const EXPECTED_DBML_WITHOUT_RECORDS = "name" varchar }`; +const DBML_WITH_INACTIVE_REF = ` +Table users { + id integer [pk] +} +Table posts { + user_id integer +} +Ref: posts.user_id > users.id [inactive] +`.trim(); + +const DBML_WITHOUT_INACTIVE_REF = ` +Table users { + id integer [pk] +} +Table posts { + user_id integer +} +Ref: posts.user_id > users.id +`.trim(); + +describe('@dbml/core - ref inactive setting', () => { + test('exports inactive ref with inactive flag', () => { + const res = exporter.export(DBML_WITH_INACTIVE_REF, 'dbml'); + expect(res).toContain('[inactive]'); + }); + + test('does not export inactive flag when setting absent', () => { + const res = exporter.export(DBML_WITHOUT_INACTIVE_REF, 'dbml'); + expect(res).not.toContain('inactive'); + }); +}); + describe('@dbml/core - exporter flags', () => { describe('includeRecords', () => { test('includes records by default', () => { diff --git a/packages/dbml-core/__tests__/examples/model_exporter/model_exporter.spec.ts b/packages/dbml-core/__tests__/examples/model_exporter/model_exporter.spec.ts index c01a900f3..41e21b8c4 100644 --- a/packages/dbml-core/__tests__/examples/model_exporter/model_exporter.spec.ts +++ b/packages/dbml-core/__tests__/examples/model_exporter/model_exporter.spec.ts @@ -208,3 +208,45 @@ describe('@dbml/core - JsonExporter isNormalized option', () => { expect(parsed.records[0].columns).toEqual(['id', 'name']); }); }); + +const DBML_WITH_INACTIVE_REF = ` +Table users { + id integer [pk] +} +Table posts { + user_id integer +} +Ref: posts.user_id > users.id [inactive] +`.trim(); + +describe('@dbml/core - ref inactive', () => { + test('model stores inactive as true', () => { + const database = (new Parser()).parse(DBML_WITH_INACTIVE_REF, 'dbmlv2'); + const ref = database.schemas[0].refs[0]; + expect(ref.inactive).toBe(true); + }); + + test('normalized model stores inactive as true', () => { + const database = (new Parser()).parse(DBML_WITH_INACTIVE_REF, 'dbmlv2'); + const normalized = database.normalize(); + const ref = Object.values(normalized.refs)[0]; + expect(ref.inactive).toBe(true); + }); + + test('DbmlExporter exports inactive flag', () => { + const database = (new Parser()).parse(DBML_WITH_INACTIVE_REF, 'dbmlv2'); + const res = DbmlExporter.export(database.normalize(), { includeRecords: false }); + expect(res).toContain('[inactive]'); + }); + + test('inactive is undefined when setting absent', () => { + const source = ` +Table users { id integer [pk] } +Table posts { user_id integer } +Ref: posts.user_id > users.id +`.trim(); + const database = (new Parser()).parse(source, 'dbmlv2'); + const ref = database.schemas[0].refs[0]; + expect(ref.inactive).toBeUndefined(); + }); +}); diff --git a/packages/dbml-core/src/export/DbmlExporter.ts b/packages/dbml-core/src/export/DbmlExporter.ts index 8c4753fa0..37fbd4cc4 100644 --- a/packages/dbml-core/src/export/DbmlExporter.ts +++ b/packages/dbml-core/src/export/DbmlExporter.ts @@ -316,6 +316,9 @@ class DbmlExporter { if (ref.onDelete) { refActions.push(`delete: ${ref.onDelete.toLowerCase()}`); } + if (ref.inactive) { + refActions.push('inactive'); + } if (refActions.length > 0) { line += ` [${refActions.join(', ')}]`; } diff --git a/packages/dbml-core/src/model_structure/ref.js b/packages/dbml-core/src/model_structure/ref.js index b35ed1dcb..8044d2a26 100644 --- a/packages/dbml-core/src/model_structure/ref.js +++ b/packages/dbml-core/src/model_structure/ref.js @@ -17,7 +17,7 @@ class Ref extends Element { * @param {import('../../types/model_structure/ref').RawRef} param0 */ constructor ({ - name, color, endpoints, onDelete, onUpdate, token, schema = {}, injectedPartial = null, + name, color, endpoints, onDelete, onUpdate, inactive, token, schema = {}, injectedPartial = null, } = {}) { super(token); /** @type {string} */ @@ -28,6 +28,8 @@ class Ref extends Element { this.onDelete = onDelete; /** @type {any} */ this.onUpdate = onUpdate; + /** @type {boolean} */ + this.inactive = inactive; /** @type {import('../../types/model_structure/endpoint').default[]} */ this.endpoints = []; /** @type {import('../../types/model_structure/schema').default} */ @@ -89,6 +91,7 @@ class Ref extends Element { color: this.color, onDelete: this.onDelete, onUpdate: this.onUpdate, + inactive: this.inactive, injectedPartialId: this.injectedPartial?.id, }; } diff --git a/packages/dbml-core/types/model_structure/ref.d.ts b/packages/dbml-core/types/model_structure/ref.d.ts index f443018dc..7437e6e23 100644 --- a/packages/dbml-core/types/model_structure/ref.d.ts +++ b/packages/dbml-core/types/model_structure/ref.d.ts @@ -10,6 +10,7 @@ export interface RawRef { endpoints: Endpoint[]; onDelete: any; onUpdate: any; + inactive?: boolean; token: Token; schema: Schema; } @@ -19,6 +20,7 @@ declare class Ref extends Element { endpoints: Endpoint[]; onDelete: any; onUpdate: any; + inactive?: boolean; schema: Schema; dbState: DbState; id: number; @@ -68,6 +70,7 @@ export interface NormalizedRef { color?: string; onUpdate?: string; onDelete?: string; + inactive?: boolean; schemaId: number; endpointIds: number[]; injectedPartialId?: number; diff --git a/packages/dbml-parse/__tests__/examples/interpreter/ref_inactive.test.ts b/packages/dbml-parse/__tests__/examples/interpreter/ref_inactive.test.ts new file mode 100644 index 000000000..46465b821 --- /dev/null +++ b/packages/dbml-parse/__tests__/examples/interpreter/ref_inactive.test.ts @@ -0,0 +1,44 @@ +import { + describe, expect, test, +} from 'vitest'; +import { + interpret, +} from '@tests/utils'; + +describe('[example] ref inactive setting', () => { + test('should set inactive to true when inactive setting present', () => { + const source = ` + Table users { id int } + Table posts { user_id int } + Ref: posts.user_id > users.id [inactive] + `; + const db = interpret(source).getValue()!; + + expect(db.refs).toHaveLength(1); + expect(db.refs[0].inactive).toBe(true); + }); + + test('should not set inactive when setting absent', () => { + const source = ` + Table users { id int } + Table posts { user_id int } + Ref: posts.user_id > users.id + `; + const db = interpret(source).getValue()!; + + expect(db.refs).toHaveLength(1); + expect(db.refs[0].inactive).toBeUndefined(); + }); + + test('should not set inactive when other settings present', () => { + const source = ` + Table users { id int } + Table posts { user_id int } + Ref: posts.user_id > users.id [delete: cascade] + `; + const db = interpret(source).getValue()!; + + expect(db.refs).toHaveLength(1); + expect(db.refs[0].inactive).toBeUndefined(); + }); +}); diff --git a/packages/dbml-parse/__tests__/examples/services/suggestions/general.test.ts b/packages/dbml-parse/__tests__/examples/services/suggestions/general.test.ts index 4f43b80eb..99c0c8c34 100644 --- a/packages/dbml-parse/__tests__/examples/services/suggestions/general.test.ts +++ b/packages/dbml-parse/__tests__/examples/services/suggestions/general.test.ts @@ -1076,19 +1076,19 @@ describe('[example] CompletionItemProvider', () => { // Test labels const labels = result.suggestions.map((s) => s.label); expect(labels).toEqual([ + 'inactive', 'update', 'delete', 'color', - ]); // Test insertTexts const insertTexts = result.suggestions.map((s) => s.insertText); expect(insertTexts).toEqual([ + 'inactive', 'update: ', 'delete: ', 'color: ', - ]); }); diff --git a/packages/dbml-parse/__tests__/examples/validator/ref_inactive_setting.test.ts b/packages/dbml-parse/__tests__/examples/validator/ref_inactive_setting.test.ts new file mode 100644 index 000000000..0e003f984 --- /dev/null +++ b/packages/dbml-parse/__tests__/examples/validator/ref_inactive_setting.test.ts @@ -0,0 +1,48 @@ +import { + describe, expect, test, +} from 'vitest'; +import { + CompileErrorCode, +} from '@/core/types/errors'; +import { + analyze, +} from '@tests/utils'; + +describe('[example] ref inactive setting', () => { + test('should accept ref with inactive setting', () => { + const source = ` + Table users { id int } + Table posts { user_id int } + Ref: posts.user_id > users.id [inactive] + `; + const errors = analyze(source).getErrors(); + + expect(errors).toHaveLength(0); + }); + + test('should reject duplicate inactive setting', () => { + const source = ` + Table users { id int } + Table posts { user_id int } + Ref: posts.user_id > users.id [inactive, inactive] + `; + const errors = analyze(source).getErrors(); + + expect(errors).toHaveLength(2); + expect(errors[0].code).toBe(CompileErrorCode.DUPLICATE_REF_SETTING); + expect(errors[0].diagnostic).toBe("'inactive' can only appear once"); + }); + + test('should reject inactive setting with a value', () => { + const source = ` + Table users { id int } + Table posts { user_id int } + Ref: posts.user_id > users.id [inactive: true] + `; + const errors = analyze(source).getErrors(); + + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe(CompileErrorCode.INVALID_REF_SETTING_VALUE); + expect(errors[0].diagnostic).toBe("'inactive' cannot have a value"); + }); +}); diff --git a/packages/dbml-parse/src/core/global_modules/ref/interpret.ts b/packages/dbml-parse/src/core/global_modules/ref/interpret.ts index b035d2d38..60a0764a4 100644 --- a/packages/dbml-parse/src/core/global_modules/ref/interpret.ts +++ b/packages/dbml-parse/src/core/global_modules/ref/interpret.ts @@ -147,6 +147,8 @@ export class RefInterpreter { : extractVariableFromExpression(updateSetting) as string; this.ref.color = settingMap.color?.length ? extractColor(settingMap.color?.at(0)?.value as any) : undefined; + + this.ref.inactive = settingMap.inactive?.length ? true : undefined; } return []; diff --git a/packages/dbml-parse/src/core/local_modules/ref/validate.ts b/packages/dbml-parse/src/core/local_modules/ref/validate.ts index d211fc6f5..4f6582877 100644 --- a/packages/dbml-parse/src/core/local_modules/ref/validate.ts +++ b/packages/dbml-parse/src/core/local_modules/ref/validate.ts @@ -241,6 +241,17 @@ export function validateFieldSettings (settings: ListExpressionNode): Report 1) { + errors.push(...attrs.map((attr) => new CompileError(CompileErrorCode.DUPLICATE_REF_SETTING, '\'inactive\' can only appear once', attr))); + } + attrs.forEach((attr) => { + if (attr.value) { + errors.push(new CompileError(CompileErrorCode.INVALID_REF_SETTING_VALUE, '\'inactive\' cannot have a value', attr!)); + } + }); + clean[name] = attrs; + break; default: attrs.forEach((attr) => errors.push(new CompileError(CompileErrorCode.UNKNOWN_REF_SETTING, `Unknown ref setting '${name}'`, attr))); } diff --git a/packages/dbml-parse/src/core/types/keywords.ts b/packages/dbml-parse/src/core/types/keywords.ts index f9da0252e..b9207a0d4 100644 --- a/packages/dbml-parse/src/core/types/keywords.ts +++ b/packages/dbml-parse/src/core/types/keywords.ts @@ -35,4 +35,5 @@ export enum SettingName { Update = 'update', Delete = 'delete', + Inactive = 'inactive', } diff --git a/packages/dbml-parse/src/core/types/schemaJson.ts b/packages/dbml-parse/src/core/types/schemaJson.ts index 8064a5f4d..982618ea7 100644 --- a/packages/dbml-parse/src/core/types/schemaJson.ts +++ b/packages/dbml-parse/src/core/types/schemaJson.ts @@ -172,6 +172,7 @@ export interface Ref { color?: string; onDelete?: string; onUpdate?: string; + inactive?: boolean; token: TokenPosition; } diff --git a/packages/dbml-parse/src/core/types/symbol/metadata.ts b/packages/dbml-parse/src/core/types/symbol/metadata.ts index 1e177dad6..5b209af48 100644 --- a/packages/dbml-parse/src/core/types/symbol/metadata.ts +++ b/packages/dbml-parse/src/core/types/symbol/metadata.ts @@ -17,13 +17,12 @@ import type { } from '../symbol'; import type { Internable } from '../internable'; import { UNHANDLED } from '../module'; -import { ElementKind } from '../keywords'; +import { ElementKind, SettingName } from '../keywords'; import { getBody, destructureComplexVariableTuple, destructureCallExpression, } from '@/core/utils/expression'; -import { TablePartial } from '../schemaJson'; export enum MetadataKind { Ref = 'ref', @@ -162,6 +161,14 @@ export class RefMetadata extends NodeMetadata { return undefined; } + active (compiler: Compiler): boolean { + if (!(this.declaration instanceof ElementDeclarationNode)) return true; + const field = getBody(this.declaration)[0]; + if (!field) return true; + const s = compiler.nodeSettings(field).getFiltered(UNHANDLED); + return !s?.[SettingName.Inactive]?.length; + } + leftToken (): SyntaxNode { // Standalone ref if (this.declaration instanceof ElementDeclarationNode) { diff --git a/packages/dbml-parse/src/services/suggestions/provider.ts b/packages/dbml-parse/src/services/suggestions/provider.ts index d40caa5eb..c90d50b34 100644 --- a/packages/dbml-parse/src/services/suggestions/provider.ts +++ b/packages/dbml-parse/src/services/suggestions/provider.ts @@ -560,16 +560,25 @@ function suggestAttributeName (compiler: Compiler, filepath: Filepath, offset: n case ScopeKind.REF: return { suggestions: [ - SettingName.Update, - SettingName.Delete, - SettingName.Color, - ].map((name) => ({ - label: name, - insertText: `${name}: `, - kind: CompletionItemKind.Property, - insertTextRules: CompletionItemInsertTextRule.KeepWhitespace, - range: undefined as any, - })), + { + label: SettingName.Inactive, + insertText: SettingName.Inactive, + kind: CompletionItemKind.Property, + insertTextRules: CompletionItemInsertTextRule.KeepWhitespace, + range: undefined as any, + }, + ...[ + SettingName.Update, + SettingName.Delete, + SettingName.Color, + ].map((name) => ({ + label: name, + insertText: `${name}: `, + kind: CompletionItemKind.Property, + insertTextRules: CompletionItemInsertTextRule.KeepWhitespace, + range: undefined as any, + })), + ], }; case ScopeKind.CHECKS: return {