Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions dbml-homepage/docs/syntax/enrichment-visualization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
32 changes: 32 additions & 0 deletions packages/dbml-core/__tests__/examples/exporter/exporter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
3 changes: 3 additions & 0 deletions packages/dbml-core/src/export/DbmlExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(', ')}]`;
}
Expand Down
5 changes: 4 additions & 1 deletion packages/dbml-core/src/model_structure/ref.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand All @@ -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} */
Expand Down Expand Up @@ -89,6 +91,7 @@ class Ref extends Element {
color: this.color,
onDelete: this.onDelete,
onUpdate: this.onUpdate,
inactive: this.inactive,
injectedPartialId: this.injectedPartial?.id,
};
}
Expand Down
3 changes: 3 additions & 0 deletions packages/dbml-core/types/model_structure/ref.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface RawRef {
endpoints: Endpoint[];
onDelete: any;
onUpdate: any;
inactive?: boolean;
token: Token;
schema: Schema;
}
Expand All @@ -19,6 +20,7 @@ declare class Ref extends Element {
endpoints: Endpoint[];
onDelete: any;
onUpdate: any;
inactive?: boolean;
schema: Schema;
dbState: DbState;
id: number;
Expand Down Expand Up @@ -68,6 +70,7 @@ export interface NormalizedRef {
color?: string;
onUpdate?: string;
onDelete?: string;
inactive?: boolean;
schemaId: number;
endpointIds: number[];
injectedPartialId?: number;
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: ',

]);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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");
});
});
2 changes: 2 additions & 0 deletions packages/dbml-parse/src/core/global_modules/ref/interpret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down
11 changes: 11 additions & 0 deletions packages/dbml-parse/src/core/local_modules/ref/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,17 @@ export function validateFieldSettings (settings: ListExpressionNode): Report<Set
});
clean[name] = attrs;
break;
case SettingName.Inactive:
if (attrs.length > 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)));
}
Expand Down
1 change: 1 addition & 0 deletions packages/dbml-parse/src/core/types/keywords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ export enum SettingName {

Update = 'update',
Delete = 'delete',
Inactive = 'inactive',
}
1 change: 1 addition & 0 deletions packages/dbml-parse/src/core/types/schemaJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export interface Ref {
color?: string;
onDelete?: string;
onUpdate?: string;
inactive?: boolean;
token: TokenPosition;
}

Expand Down
11 changes: 9 additions & 2 deletions packages/dbml-parse/src/core/types/symbol/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 19 additions & 10 deletions packages/dbml-parse/src/services/suggestions/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading