diff --git a/dbml-homepage/docs/docs.md b/dbml-homepage/docs/docs.md index b5717a861..576fad216 100644 --- a/dbml-homepage/docs/docs.md +++ b/dbml-homepage/docs/docs.md @@ -534,3 +534,26 @@ records users(id, name, age, status, created_at) { 3, 'Charlie', , Status.pending, '2024-01-15' } ``` + +### Example Records + +Records blocks can be marked as **example records**. Example records are treated as sample data — they are preserved in the DBML output but excluded from SQL `INSERT` statements during export. + +Example records are desirable if the records only serve as illustrative examples of real data. + +```text +Table users { + id int [pk] + name varchar + + records [example] { + 1, 'Alice' + 2, 'Bob' + } +} + +records users(id, name) [example] { + 1, 'Alice' + 2, 'Bob' +} +``` diff --git a/packages/dbml-core/__tests__/examples/exporter/exporter.spec.ts b/packages/dbml-core/__tests__/examples/exporter/exporter.spec.ts index f2b5d5094..ba878ff83 100644 --- a/packages/dbml-core/__tests__/examples/exporter/exporter.spec.ts +++ b/packages/dbml-core/__tests__/examples/exporter/exporter.spec.ts @@ -91,6 +91,69 @@ describe('@dbml/core - ref inactive setting', () => { }); }); +const DBML_WITH_EXAMPLE_RECORDS = ` +Table users { + id integer [pk] + name varchar +} + +Records users(id, name) [example] { + 1, 'Alice' + 2, 'Bob' +} +`.trim(); + +const DBML_WITH_MIXED_RECORDS = ` +Table users { + id integer [pk] + name varchar +} + +Table posts { + id integer [pk] + title varchar +} + +Records users(id, name) [example] { + 1, 'Alice' +} + +Records posts(id, title) { + 1, 'First Post' +} +`.trim(); + +describe('@dbml/core - records example flag', () => { + test('exports example flag in DBML output', () => { + const res = exporter.export(DBML_WITH_EXAMPLE_RECORDS, 'dbml'); + expect(res).toContain('[example]'); + expect(res).toContain('Alice'); + }); + + test('excludes example records from SQL output', () => { + for (const format of ['mysql', 'postgres', 'mssql', 'oracle'] as const) { + const res = exporter.export(DBML_WITH_EXAMPLE_RECORDS, format); + expect(res).not.toContain('INSERT'); + expect(res).not.toContain('Alice'); + } + }); + + test('exports only non-example records as SQL', () => { + for (const format of ['mysql', 'postgres', 'mssql', 'oracle'] as const) { + const res = exporter.export(DBML_WITH_MIXED_RECORDS, format); + expect(res).toContain('First Post'); + expect(res).not.toContain('Alice'); + } + }); + + test('preserves example flag in DBML roundtrip with mixed records', () => { + const res = exporter.export(DBML_WITH_MIXED_RECORDS, 'dbml'); + expect(res).toContain('[example]'); + expect(res).toContain('Alice'); + expect(res).toContain('First Post'); + }); +}); + describe('@dbml/core - exporter flags', () => { describe('includeRecords', () => { test('includes records by default', () => { diff --git a/packages/dbml-core/src/export/DbmlExporter.ts b/packages/dbml-core/src/export/DbmlExporter.ts index 37fbd4cc4..65b8d54d0 100644 --- a/packages/dbml-core/src/export/DbmlExporter.ts +++ b/packages/dbml-core/src/export/DbmlExporter.ts @@ -405,7 +405,8 @@ class DbmlExporter { ` ${row.map(formatRecordValue).join(', ')}`, ); - return `Records ${tableRef}(${columnList}) {\n${rowStrs.join('\n')}\n}\n`; + const exampleFlag = groupRecords.some((r) => r.example) ? ' [example]' : ''; + return `Records ${tableRef}(${columnList})${exampleFlag} {\n${rowStrs.join('\n')}\n}\n`; }); return recordStrs.join('\n'); diff --git a/packages/dbml-core/src/export/MysqlExporter.js b/packages/dbml-core/src/export/MysqlExporter.js index 2c8ab5135..63058bf5e 100644 --- a/packages/dbml-core/src/export/MysqlExporter.js +++ b/packages/dbml-core/src/export/MysqlExporter.js @@ -15,7 +15,7 @@ import { class MySQLExporter { static exportRecords (model) { - const records = Object.values(model.records || {}); + const records = Object.values(model.records || {}).filter((r) => !r.example); if (isEmpty(records)) { return []; } diff --git a/packages/dbml-core/src/export/OracleExporter.js b/packages/dbml-core/src/export/OracleExporter.js index b50e108e0..d73b2700f 100644 --- a/packages/dbml-core/src/export/OracleExporter.js +++ b/packages/dbml-core/src/export/OracleExporter.js @@ -18,7 +18,7 @@ import { class OracleExporter { static exportRecords (model) { - const records = Object.values(model.records || {}); + const records = Object.values(model.records || {}).filter((r) => !r.example); if (isEmpty(records)) { return []; } diff --git a/packages/dbml-core/src/export/PostgresExporter.js b/packages/dbml-core/src/export/PostgresExporter.js index b65f1a850..dd1785075 100644 --- a/packages/dbml-core/src/export/PostgresExporter.js +++ b/packages/dbml-core/src/export/PostgresExporter.js @@ -146,7 +146,7 @@ const POSTGRES_RESERVED_KEYWORDS = [ class PostgresExporter { static exportRecords (model) { - const records = Object.values(model.records || {}); + const records = Object.values(model.records || {}).filter((r) => !r.example); if (isEmpty(records)) { return []; } diff --git a/packages/dbml-core/src/export/SqlServerExporter.js b/packages/dbml-core/src/export/SqlServerExporter.js index 3e74e1fd0..a90b238a7 100644 --- a/packages/dbml-core/src/export/SqlServerExporter.js +++ b/packages/dbml-core/src/export/SqlServerExporter.js @@ -15,7 +15,7 @@ import { class SqlServerExporter { static exportRecords (model) { - const records = Object.values(model.records || {}); + const records = Object.values(model.records || {}).filter((r) => !r.example); if (isEmpty(records)) { return []; } diff --git a/packages/dbml-core/src/model_structure/database.js b/packages/dbml-core/src/model_structure/database.js index fc0a40bce..0b52ceff6 100644 --- a/packages/dbml-core/src/model_structure/database.js +++ b/packages/dbml-core/src/model_structure/database.js @@ -82,7 +82,7 @@ class Database extends Element { processRecords (rawRecords) { rawRecords.forEach(({ - schemaName, tableName, columns, values, token, + schemaName, tableName, columns, values, example, token, }) => { this.records.push({ id: this.dbState.generateId('recordId'), @@ -90,6 +90,7 @@ class Database extends Element { tableName, columns, values, + example, token, }); }); diff --git a/packages/dbml-core/types/model_structure/database.d.ts b/packages/dbml-core/types/model_structure/database.d.ts index bd7a3db88..5457f8cb2 100644 --- a/packages/dbml-core/types/model_structure/database.d.ts +++ b/packages/dbml-core/types/model_structure/database.d.ts @@ -32,6 +32,7 @@ export interface RawTableRecord { schemaName: string | undefined; tableName: string; columns: string[]; + example?: boolean; token: Token; values: { value: any; diff --git a/packages/dbml-parse/__tests__/examples/interpreter/record/example.test.ts b/packages/dbml-parse/__tests__/examples/interpreter/record/example.test.ts new file mode 100644 index 000000000..5fd21bcaa --- /dev/null +++ b/packages/dbml-parse/__tests__/examples/interpreter/record/example.test.ts @@ -0,0 +1,96 @@ +import { + describe, expect, test, +} from 'vitest'; +import { + interpret, +} from '@tests/utils'; + +describe('[example - record] example setting', () => { + test('should set example on top-level records with [example]', () => { + const source = ` + Table users { + id int [pk] + name varchar + } + records users(id, name) [example] { + 1, 'Alice' + } + `; + const result = interpret(source); + expect(result.getErrors().length).toBe(0); + + const db = result.getValue()!; + expect(db.records[0].example).toBe(true); + }); + + test('should not set example on records without [example]', () => { + const source = ` + Table users { + id int [pk] + name varchar + } + records users(id, name) { + 1, 'Alice' + } + `; + const result = interpret(source); + expect(result.getErrors().length).toBe(0); + + const db = result.getValue()!; + expect(db.records[0].example).toBeUndefined(); + }); + + test('should set example on nested records with [example]', () => { + const source = ` + Table users { + id int [pk] + name varchar + + records [example] { + 1, 'Alice' + } + } + `; + const result = interpret(source); + expect(result.getErrors().length).toBe(0); + + const db = result.getValue()!; + expect(db.records[0].example).toBe(true); + }); + + test('should set example on nested records with column list and [example]', () => { + const source = ` + Table users { + id int [pk] + name varchar + + records (id) [example] { + 1 + } + } + `; + const result = interpret(source); + expect(result.getErrors().length).toBe(0); + + const db = result.getValue()!; + expect(db.records[0].example).toBe(true); + }); + + test('should not set example on nested records without [example]', () => { + const source = ` + Table users { + id int [pk] + name varchar + + records { + 1, 'Alice' + } + } + `; + const result = interpret(source); + expect(result.getErrors().length).toBe(0); + + const db = result.getValue()!; + expect(db.records[0].example).toBeUndefined(); + }); +}); diff --git a/packages/dbml-parse/__tests__/examples/services/suggestions/suggestions_records.test.ts b/packages/dbml-parse/__tests__/examples/services/suggestions/suggestions_records.test.ts index c3434711a..7cfe6b27e 100644 --- a/packages/dbml-parse/__tests__/examples/services/suggestions/suggestions_records.test.ts +++ b/packages/dbml-parse/__tests__/examples/services/suggestions/suggestions_records.test.ts @@ -488,3 +488,53 @@ describe('[example] Suggestions Utils - Records', () => { }); }); }); + +describe('[example] CompletionItemProvider - Records settings', () => { + it('should suggest only example setting on nested records', () => { + const program = `Table users { + id int [pk] + name varchar + + records [] { + 1, 'Alice' + } +} +`; + const layout = new MemoryProjectLayout(); + layout.setSource(DEFAULT_ENTRY, program); + const compiler = new Compiler(layout); + const model = createMockTextModel(program); + const provider = new DBMLCompletionItemProvider(compiler); + // " records [] {" - inside the brackets, line 5 col 12 + const position = createPosition(5, 12); + const result = provider.provideCompletionItems(model, position); + + expect(result.suggestions.length).toBe(1); + expect(result.suggestions[0].label).toBe('example'); + expect(result.suggestions[0].insertText).toBe('example'); + }); + + it('should suggest example setting on top-level records', () => { + const program = `Table users { + id int [pk] + name varchar +} + +Records users(id, name) [] { + 1, 'Alice' +} +`; + const layout = new MemoryProjectLayout(); + layout.setSource(DEFAULT_ENTRY, program); + const compiler = new Compiler(layout); + const model = createMockTextModel(program); + const provider = new DBMLCompletionItemProvider(compiler); + // "Records users(id, name) [] {" - inside the brackets, line 6 col 26 + const position = createPosition(6, 26); + const result = provider.provideCompletionItems(model, position); + + const exampleSuggestion = result.suggestions.find((s) => s.label === 'example'); + expect(exampleSuggestion).not.toBeUndefined(); + expect(exampleSuggestion!.insertText).toBe('example'); + }); +}); diff --git a/packages/dbml-parse/__tests__/examples/validator/records.test.ts b/packages/dbml-parse/__tests__/examples/validator/records.test.ts index 40ded2793..68458efa4 100644 --- a/packages/dbml-parse/__tests__/examples/validator/records.test.ts +++ b/packages/dbml-parse/__tests__/examples/validator/records.test.ts @@ -249,4 +249,93 @@ describe('[example] records validator', () => { expect(errors.length).toBe(1); expect(errors[0].diagnostic).toContain('Records can only contain simple values'); }); + + test('should accept example setting on top-level records', () => { + const source = ` + Table users { + id int [pk] + name varchar + } + records users(id, name) [example] { + 1, "Alice" + } + `; + const errors = analyze(source).getErrors(); + expect(errors.length).toBe(0); + }); + + test('should accept example setting on nested records', () => { + const source = ` + Table users { + id int [pk] + name varchar + + records [example] { + 1, "Alice" + } + } + `; + const errors = analyze(source).getErrors(); + expect(errors.length).toBe(0); + }); + + test('should accept example setting on nested records with column list', () => { + const source = ` + Table users { + id int [pk] + name varchar + + records (id, name) [example] { + 1, "Alice" + } + } + `; + const errors = analyze(source).getErrors(); + expect(errors.length).toBe(0); + }); + + test('should reject example setting with a value', () => { + const source = ` + Table users { + id int [pk] + name varchar + } + records users(id, name) [example: true] { + 1, "Alice" + } + `; + const errors = analyze(source).getErrors(); + expect(errors.length).toBe(1); + expect(errors[0].diagnostic).toContain("'example' cannot have a value"); + }); + + test('should reject duplicate example setting', () => { + const source = ` + Table users { + id int [pk] + name varchar + } + records users(id, name) [example, example] { + 1, "Alice" + } + `; + const errors = analyze(source).getErrors(); + expect(errors.length).toBe(2); + expect(errors[0].diagnostic).toContain("'example' can only appear once"); + }); + + test('should reject unknown settings on records', () => { + const source = ` + Table users { + id int [pk] + name varchar + } + records users(id, name) [note: 'test'] { + 1, "Alice" + } + `; + const errors = analyze(source).getErrors(); + expect(errors.length).toBe(1); + expect(errors[0].diagnostic).toContain("Unknown 'note' setting"); + }); }); diff --git a/packages/dbml-parse/src/core/global_modules/records/interpret.ts b/packages/dbml-parse/src/core/global_modules/records/interpret.ts index d4ccbc809..c8ddb025d 100644 --- a/packages/dbml-parse/src/core/global_modules/records/interpret.ts +++ b/packages/dbml-parse/src/core/global_modules/records/interpret.ts @@ -79,11 +79,13 @@ export default class RecordsInterpreter { } const token = getTokenPosition(this.element); + const example = this.metadata.example(this.compiler) || undefined; const tableRecord: TableRecord = { schemaName: result.schemaName, tableName: result.tableName, columns: result.columns.map((c) => c.name ?? ''), values, + example, token, }; diff --git a/packages/dbml-parse/src/core/local_modules/records/index.ts b/packages/dbml-parse/src/core/local_modules/records/index.ts index d46430c79..e88578d34 100644 --- a/packages/dbml-parse/src/core/local_modules/records/index.ts +++ b/packages/dbml-parse/src/core/local_modules/records/index.ts @@ -15,6 +15,7 @@ import { } from '@/core/utils/validate'; import { type LocalModule, type Settings } from '../types'; import RecordsValidator from './validate'; +import { validateRecordsSettings } from './validate'; import { DEFAULT_SCHEMA_NAME } from '@/constants'; export const recordsModule: LocalModule = { @@ -106,12 +107,8 @@ export const recordsModule: LocalModule = { nodeSettings (compiler: Compiler, node: SyntaxNode): Report | Report { if (isElementNode(node, ElementKind.Records)) { - if (node.attributeList) { - return new Report({}, [ - new CompileError(CompileErrorCode.UNEXPECTED_SETTINGS, 'Records cannot have a setting list', node.attributeList), - ]); - } - return new Report({}); + if (!node.attributeList) return new Report({}); + return validateRecordsSettings(node.attributeList); } if (isElementFieldNode(node, ElementKind.Records)) { return new Report({}); diff --git a/packages/dbml-parse/src/core/local_modules/records/validate.ts b/packages/dbml-parse/src/core/local_modules/records/validate.ts index 335d4759b..753d16e16 100644 --- a/packages/dbml-parse/src/core/local_modules/records/validate.ts +++ b/packages/dbml-parse/src/core/local_modules/records/validate.ts @@ -1,8 +1,9 @@ import { partition } from 'lodash-es'; +import { forIn } from 'lodash-es'; import Compiler from '@/compiler'; import { KEYWORDS_OF_DEFAULT_SETTING } from '@/constants'; import { CompileError, CompileErrorCode } from '@/core/types/errors'; -import { ElementKind } from '@/core/types/keywords'; +import { ElementKind, SettingName } from '@/core/types/keywords'; import { BlockExpressionNode, CallExpressionNode, @@ -16,8 +17,11 @@ import { SyntaxNode, WildcardNode, } from '@/core/types/nodes'; +import Report from '@/core/types/report'; import { destructureComplexVariable } from '@/core/utils/expression'; -import { isAccessExpression, isExpressionAQuotedString, isExpressionAVariableNode } from '@/core/utils/validate'; +import { + type Settings, aggregateSettingList, isAccessExpression, isExpressionAQuotedString, isExpressionAVariableNode, +} from '@/core/utils/validate'; import { isExpressionASignedNumberExpression, isTupleOfVariables, isValidName } from '@/core/utils/validate'; export default class RecordsValidator { @@ -151,12 +155,7 @@ export default class RecordsValidator { } private validateSettingList (settingList?: ListExpressionNode): CompileError[] { - if (settingList) { - return [ - new CompileError(CompileErrorCode.UNEXPECTED_SETTINGS, 'Records cannot have a setting list', settingList), - ]; - } - return []; + return validateRecordsSettings(settingList).getErrors(); } // Validate that records body contains only simple values (one comma-separated row per line). @@ -284,3 +283,27 @@ export default class RecordsValidator { }); } } + +export function validateRecordsSettings (settingList?: ListExpressionNode): Report { + const aggReport = aggregateSettingList(settingList); + const errors = aggReport.getErrors(); + const settingMap = aggReport.getValue(); + + forIn(settingMap, (attrs, name) => { + switch (name) { + case SettingName.Example: + if (attrs.length > 1) { + errors.push(...attrs.map((attr) => new CompileError(CompileErrorCode.DUPLICATE_RECORDS_SETTING, '\'example\' can only appear once', attr))); + } + attrs.forEach((attr) => { + if (attr.value) { + errors.push(new CompileError(CompileErrorCode.INVALID_RECORDS_SETTING_VALUE, '\'example\' cannot have a value', attr!)); + } + }); + break; + default: + errors.push(...attrs.map((attr) => new CompileError(CompileErrorCode.UNKNOWN_RECORDS_SETTING, `Unknown '${name}' setting`, attr))); + } + }); + return new Report(settingMap, errors); +} diff --git a/packages/dbml-parse/src/core/types/errors.ts b/packages/dbml-parse/src/core/types/errors.ts index 7131e965b..5dd71f1bc 100644 --- a/packages/dbml-parse/src/core/types/errors.ts +++ b/packages/dbml-parse/src/core/types/errors.ts @@ -115,6 +115,9 @@ export enum CompileErrorCode { INVALID_RECORDS_FIELD, DUPLICATE_COLUMN_REFERENCES_IN_RECORDS, DUPLICATE_RECORDS_FOR_TABLE, + UNKNOWN_RECORDS_SETTING, + DUPLICATE_RECORDS_SETTING, + INVALID_RECORDS_SETTING_VALUE, INVALID_USE_CONTEXT, INVALID_USE_SPECIFIER_KIND, diff --git a/packages/dbml-parse/src/core/types/keywords.ts b/packages/dbml-parse/src/core/types/keywords.ts index b9207a0d4..c718ffa5a 100644 --- a/packages/dbml-parse/src/core/types/keywords.ts +++ b/packages/dbml-parse/src/core/types/keywords.ts @@ -36,4 +36,5 @@ export enum SettingName { Update = 'update', Delete = 'delete', Inactive = 'inactive', + Example = 'example', } diff --git a/packages/dbml-parse/src/core/types/schemaJson.ts b/packages/dbml-parse/src/core/types/schemaJson.ts index 982618ea7..9f1b48050 100644 --- a/packages/dbml-parse/src/core/types/schemaJson.ts +++ b/packages/dbml-parse/src/core/types/schemaJson.ts @@ -262,6 +262,7 @@ export interface TableRecord { tableName: string; columns: string[]; values: RecordValue[][]; + example?: 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 5b209af48..80e56bf64 100644 --- a/packages/dbml-parse/src/core/types/symbol/metadata.ts +++ b/packages/dbml-parse/src/core/types/symbol/metadata.ts @@ -376,6 +376,11 @@ export class RecordsMetadata extends NodeMetadata { return sym?.originalSymbol as TableSymbol | undefined; } + example (compiler: Compiler): boolean { + const s = compiler.nodeSettings(this.declaration as ElementDeclarationNode).getFiltered(UNHANDLED); + return !!s?.[SettingName.Example]?.length; + } + override owners (compiler: Compiler): NodeSymbol[] { const tableSymbol = this.table(compiler); if (!tableSymbol) return []; diff --git a/packages/dbml-parse/src/services/monarch.ts b/packages/dbml-parse/src/services/monarch.ts index c158eb048..5c764a9a0 100644 --- a/packages/dbml-parse/src/services/monarch.ts +++ b/packages/dbml-parse/src/services/monarch.ts @@ -199,6 +199,7 @@ const dbmlMonarchTokensProvider: MonarchLanguage = { 'notes', 'schemas', 'inactive', + 'example', ], symbols: /[=>