diff --git a/dbml-playground/package.json b/dbml-playground/package.json index 062895d15..d7cc28b1d 100644 --- a/dbml-playground/package.json +++ b/dbml-playground/package.json @@ -1,6 +1,6 @@ { "name": "@dbml/playground", - "version": "8.3.1", + "version": "9.0.0-optional-ref.1", "description": "Interactive playground for debugging and visualizing the DBML parser pipeline", "author": "Holistics ", "license": "Apache-2.0", @@ -25,8 +25,8 @@ "format": "prettier --write src/" }, "dependencies": { - "@dbml/core": "^8.3.1", - "@dbml/parse": "^8.3.1", + "@dbml/core": "^9.0.0-optional-ref.1", + "@dbml/parse": "^9.0.0-optional-ref.0", "@phosphor-icons/vue": "^2.2.0", "floating-vue": "^5.2.2", "lodash-es": "^4.17.21", diff --git a/dbml-playground/src/components/editor/dbml_services.ts b/dbml-playground/src/components/editor/dbml_services.ts index 2cce39cf4..d99e086a5 100644 --- a/dbml-playground/src/components/editor/dbml_services.ts +++ b/dbml-playground/src/components/editor/dbml_services.ts @@ -17,6 +17,7 @@ export async function setupDbmlServices (compiler: Compiler): Promise { monaco.languages.registerDefinitionProvider(languageId, services.definitionProvider as any); monaco.languages.registerReferenceProvider(languageId, services.referenceProvider as any); monaco.languages.registerCompletionItemProvider(languageId, services.autocompletionProvider as any); + monaco.languages.registerCodeActionProvider(languageId, services.codeActionProvider as any); } export function updateDiagnosticMarkers (model: monaco.editor.ITextModel): void { diff --git a/lerna.json b/lerna.json index 9ed5a08fc..2b382eda9 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "8.3.1", + "version": "9.0.0-optional-ref.1", "npmClient": "yarn", "$schema": "node_modules/lerna/schemas/lerna-schema.json" } diff --git a/packages/dbml-cli/__tests__/db2dbml/mssql/expect-out-files/schema.dbml b/packages/dbml-cli/__tests__/db2dbml/mssql/expect-out-files/schema.dbml index 19e300178..6292324cc 100644 --- a/packages/dbml-cli/__tests__/db2dbml/mssql/expect-out-files/schema.dbml +++ b/packages/dbml-cli/__tests__/db2dbml/mssql/expect-out-files/schema.dbml @@ -223,4 +223,4 @@ Ref "fk_order":"dbo"."orders"."order_id" < "dbo"."order_items"."order_id" [updat Ref "fk_user":"dbo"."users"."user_id" < "dbo"."orders"."user_id" [update: cascade, delete: cascade] -Ref "fk_gender":"dbo"."gender_reference"."value" < "dbo"."user_define_data_types"."gender" +Ref "fk_gender":"dbo"."gender_reference"."value" { }); }); +describe('@dbml/core - optional ref operators', () => { + const OPTIONAL_REF_OPS = [ + '-', '-?', '?-', '?-?', + '>', '>?', '?>', '?>?', + '<', '', '<>?', '?<>', '?<>?', + ]; + + const EXPECTED_OP: Record = { + '-': '-', '-?': '-?', '?-': '?-', '?-?': '?-?', + '>': '<', '>?': '?<', '?>': '?': '?': '<>', '<>?': '<>?', '?<>': '?<>', '?<>?': '?<>?', + }; + + describe('dbml exporter', () => { + test.each(OPTIONAL_REF_OPS)('should export ref with operator %s', (op) => { + const input = ` + Table users { id integer [pk] } + Table posts { user_id integer } + Ref: posts.user_id ${op} users.id + `.trim(); + const res = exporter.export(input, 'dbml'); + expect(res).toContain(EXPECTED_OP[op]); + }); + }); + + describe('sql exporters', () => { + const sqlFormats: ExportFormat[] = ['mysql', 'postgres', 'mssql']; + + test.each(sqlFormats)('%s exporter should handle optional ref without error', (format) => { + const input = ` + Table users { id integer [pk] } + Table posts { user_id integer } + Ref: posts.user_id >? users.id + `.trim(); + expect(() => exporter.export(input, format)).not.toThrow(); + }); + + test.each(sqlFormats)('%s exporter should produce FK constraint for optional ref', (format) => { + const input = ` + Table users { id integer [pk] } + Table posts { user_id integer } + Ref: posts.user_id >? users.id + `.trim(); + const res = exporter.export(input, format); + expect(res).toContain('FOREIGN KEY'); + expect(res).toContain('REFERENCES'); + }); + }); +}); + describe('@dbml/core - exporter flags', () => { describe('includeRecords', () => { test('includes records by default', () => { diff --git a/packages/dbml-core/__tests__/examples/importer/importer.spec.ts b/packages/dbml-core/__tests__/examples/importer/importer.spec.ts index cf58ebe9c..937cc6461 100644 --- a/packages/dbml-core/__tests__/examples/importer/importer.spec.ts +++ b/packages/dbml-core/__tests__/examples/importer/importer.spec.ts @@ -5,6 +5,60 @@ import { readFileSync } from 'fs'; import path from 'path'; import { test, expect, describe } from 'vitest'; +describe('@dbml/core - importer optional refs', () => { + describe('postgres', () => { + test('NOT NULL FK to PK', () => { + const sql = ` + CREATE TABLE a (id int PRIMARY KEY); + CREATE TABLE b (id int PRIMARY KEY, a_id int NOT NULL REFERENCES a(id)); + `; + expect(importer.import(sql, 'postgres')).toContain('Ref:"a"."id" < "b"."a_id"'); + }); + + test('nullable FK to PK', () => { + const sql = ` + CREATE TABLE a (id int PRIMARY KEY); + CREATE TABLE b (id int PRIMARY KEY, a_id int REFERENCES a(id)); + `; + expect(importer.import(sql, 'postgres')).toContain('Ref:"a"."id" { + const sql = ` + CREATE TABLE a (id int PRIMARY KEY, code int UNIQUE NOT NULL); + CREATE TABLE b (id int PRIMARY KEY, a_code int NOT NULL REFERENCES a(code)); + `; + expect(importer.import(sql, 'postgres')).toContain('Ref:"a"."code" < "b"."a_code"'); + }); + + test('nullable FK to UNIQUE', () => { + const sql = ` + CREATE TABLE a (id int PRIMARY KEY, code int UNIQUE NOT NULL); + CREATE TABLE b (id int PRIMARY KEY, a_code int REFERENCES a(code)); + `; + expect(importer.import(sql, 'postgres')).toContain('Ref:"a"."code" { + test('NOT NULL FK to PK', () => { + const sql = ` + CREATE TABLE a (id int PRIMARY KEY); + CREATE TABLE b (id int PRIMARY KEY, a_id int NOT NULL, FOREIGN KEY (a_id) REFERENCES a(id)); + `; + expect(importer.import(sql, 'mysql')).toContain('Ref:"a"."id" < "b"."a_id"'); + }); + + test('nullable FK to PK', () => { + const sql = ` + CREATE TABLE a (id int PRIMARY KEY); + CREATE TABLE b (id int PRIMARY KEY, a_id int, FOREIGN KEY (a_id) REFERENCES a(id)); + `; + expect(importer.import(sql, 'mysql')).toContain('Ref:"a"."id" { const runTest = async (fileName: string, testDir: string, format: ParseFormat) => { const fileExtension = getFileExtension(format); diff --git a/packages/dbml-core/__tests__/examples/importer/json_importer/output/general_schema.out.dbml b/packages/dbml-core/__tests__/examples/importer/json_importer/output/general_schema.out.dbml index 4ba4e7f91..35d6d09fb 100644 --- a/packages/dbml-core/__tests__/examples/importer/json_importer/output/general_schema.out.dbml +++ b/packages/dbml-core/__tests__/examples/importer/json_importer/output/general_schema.out.dbml @@ -61,14 +61,14 @@ Table "countries" { "continent_name" varchar } -Ref:"orders"."id" < "order_items"."order_id" +Ref:"orders"."id" "ecommerce"."products"."id" diff --git a/packages/dbml-core/__tests__/examples/model_exporter/dbml_exporter/output/general_schema.out.dbml b/packages/dbml-core/__tests__/examples/model_exporter/dbml_exporter/output/general_schema.out.dbml index 4ba4e7f91..35d6d09fb 100644 --- a/packages/dbml-core/__tests__/examples/model_exporter/dbml_exporter/output/general_schema.out.dbml +++ b/packages/dbml-core/__tests__/examples/model_exporter/dbml_exporter/output/general_schema.out.dbml @@ -61,14 +61,14 @@ Table "countries" { "continent_name" varchar } -Ref:"orders"."id" < "order_items"."order_id" +Ref:"orders"."id" "ecommerce"."products"."id" -Ref:"ecommerce"."merchants".("id", "country_code") < "ecommerce"."merchant_periods".("merchant_id", "country_code") +Ref:"ecommerce"."merchants".("id", "country_code") ? { schemaName: DEFAULT_SCHEMA_NAME, tableName: 'users', fieldNames: ['country_code'], - relation: '*', + relation: '0..*', }, { schemaName: DEFAULT_SCHEMA_NAME, @@ -125,7 +125,7 @@ describe('@dbml/core - model_structure', () => { 'id', 'name', ], - relation: '1', + relation: '0..1', }, { schemaName: DEFAULT_SCHEMA_NAME, @@ -134,7 +134,7 @@ describe('@dbml/core - model_structure', () => { 'product_id', 'product_name', ], - relation: '*', + relation: '0..*', }, ]), }), @@ -473,7 +473,7 @@ describe('@dbml/core - model_structure', () => { schemaName: DEFAULT_SCHEMA_NAME, tableName: 'users', fieldNames: ['country_code'], - relation: '*', + relation: '0..*', }, { schemaName: DEFAULT_SCHEMA_NAME, @@ -492,7 +492,7 @@ describe('@dbml/core - model_structure', () => { 'id', 'name', ], - relation: '1', + relation: '0..1', }, { schemaName: DEFAULT_SCHEMA_NAME, @@ -501,7 +501,7 @@ describe('@dbml/core - model_structure', () => { 'product_id', 'product_name', ], - relation: '*', + relation: '0..*', }, ]), }), diff --git a/packages/dbml-core/__tests__/examples/normalized_model/schema_def.out.json b/packages/dbml-core/__tests__/examples/normalized_model/schema_def.out.json index 202c59354..7ac865c5f 100644 --- a/packages/dbml-core/__tests__/examples/normalized_model/schema_def.out.json +++ b/packages/dbml-core/__tests__/examples/normalized_model/schema_def.out.json @@ -99,7 +99,6 @@ "refs": { "1": { "id": 1, - "name": null, "endpointIds": [ 1, 2 @@ -108,7 +107,6 @@ }, "2": { "id": 2, - "name": null, "endpointIds": [ 3, 4 @@ -223,6 +221,7 @@ "alias": "EU", "note": null, "partials": [], + "recordIds": [], "fieldIds": [ 1, 2, @@ -232,6 +231,7 @@ 6 ], "indexIds": [], + "checkIds": [], "schemaId": 3, "groupId": 1 }, @@ -241,6 +241,7 @@ "alias": null, "note": null, "partials": [], + "recordIds": [], "fieldIds": [ 7, 8, @@ -250,6 +251,7 @@ 12 ], "indexIds": [], + "checkIds": [], "schemaId": 1, "groupId": 1 }, @@ -259,11 +261,13 @@ "alias": null, "note": null, "partials": [], + "recordIds": [], "fieldIds": [ 13, 14 ], "indexIds": [], + "checkIds": [], "schemaId": 1, "groupId": 1 }, @@ -273,11 +277,13 @@ "alias": "A", "note": null, "partials": [], + "recordIds": [], "fieldIds": [ 15, 16 ], "indexIds": [], + "checkIds": [], "schemaId": 4, "groupId": 1 }, @@ -287,11 +293,13 @@ "alias": null, "note": null, "partials": [], + "recordIds": [], "fieldIds": [ 17, 18 ], "indexIds": [], + "checkIds": [], "schemaId": 4, "groupId": null }, @@ -302,11 +310,13 @@ "note": null, "headerColor": "#aaaaaa", "partials": [], + "recordIds": [], "fieldIds": [ 19, 20 ], "indexIds": [], + "checkIds": [], "schemaId": 1, "groupId": null }, @@ -316,11 +326,13 @@ "alias": null, "note": null, "partials": [], + "recordIds": [], "fieldIds": [ 21, 22 ], "indexIds": [], + "checkIds": [], "schemaId": 1, "groupId": null }, @@ -330,12 +342,14 @@ "alias": null, "note": null, "partials": [], + "recordIds": [], "fieldIds": [ 23, 24, 25 ], "indexIds": [], + "checkIds": [], "schemaId": 1, "groupId": null }, @@ -357,18 +371,23 @@ "offset": 1539, "line": 108, "column": 22 + }, + "filepath": { + "path": "/main.dbml" } }, "name": "P_unary_expression", "id": 1 } ], + "recordIds": [], "fieldIds": [ 26, 27, 28 ], "indexIds": [], + "checkIds": [], "schemaId": 1, "groupId": null } @@ -394,7 +413,7 @@ "fieldNames": [ "name" ], - "relation": "*", + "relation": "0..*", "refId": 1, "fieldIds": [ 16 @@ -420,7 +439,7 @@ "fieldNames": [ "name" ], - "relation": "*", + "relation": "0..*", "refId": 2, "fieldIds": [ 18 @@ -459,7 +478,7 @@ "fieldNames": [ "name" ], - "relation": "1", + "relation": "0..1", "refId": 4, "fieldIds": [ 8 @@ -485,7 +504,7 @@ "fieldNames": [ "id" ], - "relation": "1", + "relation": "0..1", "refId": 5, "fieldIds": [ 19 @@ -498,7 +517,7 @@ "fieldNames": [ "id" ], - "relation": "*", + "relation": "0..*", "refId": 5, "fieldIds": [ 21 @@ -511,7 +530,7 @@ "fieldNames": [ "id" ], - "relation": "1", + "relation": "0..1", "refId": 6, "fieldIds": [ 21 @@ -524,7 +543,7 @@ "fieldNames": [ "c_id" ], - "relation": "*", + "relation": "0..*", "refId": 6, "fieldIds": [ 20 @@ -583,6 +602,7 @@ }, "indexes": {}, "indexColumns": {}, + "checks": {}, "fields": { "1": { "id": 1, @@ -595,6 +615,8 @@ "unique": false, "pk": true, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [ 1, 6, @@ -614,6 +636,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 1, "enumId": null @@ -629,6 +653,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 1, "enumId": 1 @@ -644,6 +670,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 1, "enumId": 1 @@ -659,6 +687,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 1, "enumId": 2 @@ -674,6 +704,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 1, "enumId": 3 @@ -689,6 +721,8 @@ "unique": false, "pk": true, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [ 3, 5 @@ -707,6 +741,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [ 7 ], @@ -724,6 +760,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 2, "enumId": 1 @@ -739,6 +777,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 2, "enumId": 1 @@ -754,6 +794,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 2, "enumId": 2 @@ -769,6 +811,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 2, "enumId": 3 @@ -784,6 +828,8 @@ "unique": false, "pk": true, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 3, "enumId": null @@ -799,6 +845,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 3, "enumId": null @@ -814,6 +862,8 @@ "unique": false, "pk": true, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 4, "enumId": null @@ -829,6 +879,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [ 2 ], @@ -846,6 +898,8 @@ "unique": false, "pk": true, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 5, "enumId": null @@ -861,6 +915,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [ 4 ], @@ -878,6 +934,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [ 9 ], @@ -895,6 +953,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [ 12 ], @@ -912,6 +972,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [ 10, 11 @@ -930,6 +992,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 7, "enumId": null @@ -949,6 +1013,8 @@ "type": "number", "value": -2 }, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 8, "enumId": null @@ -968,6 +1034,8 @@ "type": "number", "value": -2 }, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 8, "enumId": null @@ -987,6 +1055,8 @@ "type": "number", "value": 7.2225 }, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 8, "enumId": null @@ -1002,6 +1072,8 @@ "unique": false, "pk": false, "note": null, + "injectedPartialId": null, + "checkIds": [], "endpointIds": [], "tableId": 9, "enumId": null @@ -1020,6 +1092,7 @@ "value": -2 }, "injectedPartialId": 1, + "checkIds": [], "endpointIds": [], "tableId": 9, "enumId": null @@ -1038,6 +1111,7 @@ "value": -7.2225 }, "injectedPartialId": 1, + "checkIds": [], "endpointIds": [], "tableId": 9, "enumId": null @@ -1052,11 +1126,6 @@ "fields": [ { "name": "id", - "type": { - "schemaName": null, - "type_name": "int(-1)", - "args": "-1" - }, "token": { "start": { "offset": 1356, @@ -1067,21 +1136,25 @@ "offset": 1380, "line": 101, "column": 27 + }, + "filepath": { + "path": "/main.dbml" } }, - "inline_refs": [], + "type": { + "schemaName": null, + "type_name": "int(-1)", + "args": "-1" + }, "dbdefault": { "type": "number", "value": -2 - } + }, + "inline_refs": [], + "checks": [] }, { "name": "id2", - "type": { - "schemaName": null, - "type_name": "int(--1)", - "args": "--1" - }, "token": { "start": { "offset": 1383, @@ -1092,21 +1165,25 @@ "offset": 1410, "line": 102, "column": 30 + }, + "filepath": { + "path": "/main.dbml" } }, - "inline_refs": [], + "type": { + "schemaName": null, + "type_name": "int(--1)", + "args": "--1" + }, "dbdefault": { "type": "number", "value": -2 - } + }, + "inline_refs": [], + "checks": [] }, { "name": "id3", - "type": { - "schemaName": null, - "type_name": "int(+-+---+0.1)", - "args": "+-+---+0.1" - }, "token": { "start": { "offset": 1413, @@ -1117,16 +1194,25 @@ "offset": 1459, "line": 103, "column": 49 + }, + "filepath": { + "path": "/main.dbml" } }, - "inline_refs": [], + "type": { + "schemaName": null, + "type_name": "int(+-+---+0.1)", + "args": "+-+---+0.1" + }, "dbdefault": { "type": "number", "value": -7.2225 - } + }, + "inline_refs": [], + "checks": [] } ], "indexes": [] } } -} +} \ No newline at end of file diff --git a/packages/dbml-core/__tests__/examples/normalized_model/table_partial.out.json b/packages/dbml-core/__tests__/examples/normalized_model/table_partial.out.json index ff045a25f..aecca6674 100644 --- a/packages/dbml-core/__tests__/examples/normalized_model/table_partial.out.json +++ b/packages/dbml-core/__tests__/examples/normalized_model/table_partial.out.json @@ -333,7 +333,7 @@ "fieldNames": [ "id2" ], - "relation": "1", + "relation": "0..1", "refId": 1, "fieldIds": [ 2 @@ -346,7 +346,7 @@ "fieldNames": [ "refColumn1" ], - "relation": "*", + "relation": "0..*", "refId": 1, "fieldIds": [ 6 @@ -359,7 +359,7 @@ "fieldNames": [ "refColumn2" ], - "relation": "*", + "relation": "0..*", "refId": 2, "fieldIds": [ 7 @@ -372,7 +372,7 @@ "fieldNames": [ "id" ], - "relation": "1", + "relation": "0..1", "refId": 2, "fieldIds": [ 3 diff --git a/packages/dbml-core/package.json b/packages/dbml-core/package.json index 1b571fcc4..d8f076941 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": "8.3.1", + "version": "9.0.0-optional-ref.1", "description": "> TODO: description", "author": "Holistics ", "license": "Apache-2.0", @@ -46,7 +46,7 @@ "lint:fix": "eslint --fix ." }, "dependencies": { - "@dbml/parse": "^8.3.1", + "@dbml/parse": "^9.0.0-optional-ref.0", "antlr4": "^4.13.1", "lodash": "^4.18.1", "lodash-es": "^4.18.1", diff --git a/packages/dbml-core/src/export/DbmlExporter.ts b/packages/dbml-core/src/export/DbmlExporter.ts index 02b40e1a9..aa57524f1 100644 --- a/packages/dbml-core/src/export/DbmlExporter.ts +++ b/packages/dbml-core/src/export/DbmlExporter.ts @@ -1,5 +1,5 @@ import { groupBy, isEmpty, reduce } from 'lodash-es'; -import { addDoubleQuoteIfNeeded, formatRecordValue } from '@dbml/parse'; +import { addDoubleQuoteIfNeeded, formatRecordValue, getRelationshipOp, parseCardinality } from '@dbml/parse'; import { shouldPrintSchema } from './utils'; import { DEFAULT_SCHEMA_NAME } from '../model_structure/config'; import type { NormalizedModel, RecordValue } from '../../types/model_structure/database'; @@ -273,19 +273,19 @@ class DbmlExporter { static exportRefs (refIds: number[], model: NormalizedModel): string { const strArr = refIds.map((refId) => { const ref = model.refs[refId]; - const oneRelationEndpointIndex = ref.endpointIds.findIndex((endpointId) => model.endpoints[endpointId].relation === '1'); - const isManyToMany = oneRelationEndpointIndex === -1; - const refEndpointIndex = isManyToMany ? 0 : oneRelationEndpointIndex; - const foreignEndpointId = ref.endpointIds[1 - refEndpointIndex]; - const refEndpointId = ref.endpointIds[refEndpointIndex]; - const foreignEndpoint = model.endpoints[foreignEndpointId]; - const refEndpoint = model.endpoints[refEndpointId]; + // Put the "one" side on the left to preserve canonical order (one < many) + const oneIndex = ref.endpointIds.findIndex((id) => parseCardinality(model.endpoints[id].relation).max === 1); + const leftIndex = oneIndex === -1 ? 0 : oneIndex; + const leftEndpoint = model.endpoints[ref.endpointIds[leftIndex]]; + const rightEndpoint = model.endpoints[ref.endpointIds[1 - leftIndex]]; + const op = getRelationshipOp(leftEndpoint.relation, rightEndpoint.relation); + + const leftField = model.fields[leftEndpoint.fieldIds[0]]; + const leftTable = model.tables[leftField.tableId]; + const leftSchema = model.schemas[leftTable.schemaId]; + const leftFieldName = DbmlExporter.buildFieldName(leftEndpoint.fieldIds, model); let line = 'Ref'; - const refEndpointField = model.fields[refEndpoint.fieldIds[0]]; - const refEndpointTable = model.tables[refEndpointField.tableId]; - const refEndpointSchema = model.schemas[refEndpointTable.schemaId]; - const refEndpointFieldName = DbmlExporter.buildFieldName(refEndpoint.fieldIds, model); if (ref.name) { line += ` ${shouldPrintSchema(model.schemas[ref.schemaId], model) @@ -293,22 +293,19 @@ class DbmlExporter { : ''}"${ref.name}"`; } line += ':'; - line += `${shouldPrintSchema(refEndpointSchema, model) - ? `"${refEndpointSchema.name}".` - : ''}"${refEndpointTable.name}".${refEndpointFieldName} `; - - const foreignEndpointField = model.fields[foreignEndpoint.fieldIds[0]]; - const foreignEndpointTable = model.tables[foreignEndpointField.tableId]; - const foreignEndpointSchema = model.schemas[foreignEndpointTable.schemaId]; - const foreignEndpointFieldName = DbmlExporter.buildFieldName(foreignEndpoint.fieldIds, model); - - if (isManyToMany) line += '<> '; - else - if (foreignEndpoint.relation === '1') line += '- '; - else line += '< '; - line += `${shouldPrintSchema(foreignEndpointSchema, model) - ? `"${foreignEndpointSchema.name}".` - : ''}"${foreignEndpointTable.name}".${foreignEndpointFieldName}`; + line += `${shouldPrintSchema(leftSchema, model) + ? `"${leftSchema.name}".` + : ''}"${leftTable.name}".${leftFieldName} `; + + const rightField = model.fields[rightEndpoint.fieldIds[0]]; + const rightTable = model.tables[rightField.tableId]; + const rightSchema = model.schemas[rightTable.schemaId]; + const rightFieldName = DbmlExporter.buildFieldName(rightEndpoint.fieldIds, model); + + line += `${op} `; + line += `${shouldPrintSchema(rightSchema, model) + ? `"${rightSchema.name}".` + : ''}"${rightTable.name}".${rightFieldName}`; const refActions: string[] = []; if (ref.onUpdate) { diff --git a/packages/dbml-core/src/export/MysqlExporter.js b/packages/dbml-core/src/export/MysqlExporter.js index 2c8ab5135..7d3f59b8b 100644 --- a/packages/dbml-core/src/export/MysqlExporter.js +++ b/packages/dbml-core/src/export/MysqlExporter.js @@ -1,4 +1,5 @@ import { + parseCardinality, isBinaryType, isBooleanType, isDateTimeType, @@ -248,7 +249,7 @@ class MySQLExporter { const strArr = refIds.map((refId) => { let line = ''; const ref = model.refs[refId]; - const refOneIndex = ref.endpointIds.findIndex((endpointId) => model.endpoints[endpointId].relation === '1'); + const refOneIndex = ref.endpointIds.findIndex((endpointId) => parseCardinality(model.endpoints[endpointId].relation).max === 1); const refEndpointIndex = refOneIndex === -1 ? 0 : refOneIndex; const foreignEndpointId = ref.endpointIds[1 - refEndpointIndex]; const refEndpointId = ref.endpointIds[refEndpointIndex]; diff --git a/packages/dbml-core/src/export/OracleExporter.js b/packages/dbml-core/src/export/OracleExporter.js index b50e108e0..9b2dd789c 100644 --- a/packages/dbml-core/src/export/OracleExporter.js +++ b/packages/dbml-core/src/export/OracleExporter.js @@ -1,4 +1,5 @@ import { + parseCardinality, isBinaryType, isBooleanType, isDateTimeType, @@ -323,7 +324,7 @@ class OracleExporter { const ref = model.refs[refId]; // find the one relation in one-to-xxx or xxx-to-one relationship - const refOneIndex = ref.endpointIds.findIndex((endpointId) => model.endpoints[endpointId].relation === '1'); + const refOneIndex = ref.endpointIds.findIndex((endpointId) => parseCardinality(model.endpoints[endpointId].relation).max === 1); const refEndpointIndex = refOneIndex === -1 ? 0 : refOneIndex; diff --git a/packages/dbml-core/src/export/PostgresExporter.js b/packages/dbml-core/src/export/PostgresExporter.js index b65f1a850..4b675c337 100644 --- a/packages/dbml-core/src/export/PostgresExporter.js +++ b/packages/dbml-core/src/export/PostgresExporter.js @@ -1,4 +1,5 @@ import { + parseCardinality, isBinaryType, isBooleanType, isDateTimeType, @@ -415,7 +416,7 @@ class PostgresExporter { const strArr = refIds.map((refId) => { let line = ''; const ref = model.refs[refId]; - const refOneIndex = ref.endpointIds.findIndex((endpointId) => model.endpoints[endpointId].relation === '1'); + const refOneIndex = ref.endpointIds.findIndex((endpointId) => parseCardinality(model.endpoints[endpointId].relation).max === 1); const refEndpointIndex = refOneIndex === -1 ? 0 : refOneIndex; const foreignEndpointId = ref.endpointIds[1 - refEndpointIndex]; const refEndpointId = ref.endpointIds[refEndpointIndex]; diff --git a/packages/dbml-core/src/export/SqlServerExporter.js b/packages/dbml-core/src/export/SqlServerExporter.js index 3e74e1fd0..22c82b61f 100644 --- a/packages/dbml-core/src/export/SqlServerExporter.js +++ b/packages/dbml-core/src/export/SqlServerExporter.js @@ -1,4 +1,5 @@ import { + parseCardinality, isBinaryType, isBooleanType, isDateTimeType, @@ -248,7 +249,7 @@ class SqlServerExporter { const strArr = refIds.map((refId) => { let line = ''; const ref = model.refs[refId]; - const refOneIndex = ref.endpointIds.findIndex((endpointId) => model.endpoints[endpointId].relation === '1'); + const refOneIndex = ref.endpointIds.findIndex((endpointId) => parseCardinality(model.endpoints[endpointId].relation).max === 1); const refEndpointIndex = refOneIndex === -1 ? 0 : refOneIndex; const foreignEndpointId = ref.endpointIds[1 - refEndpointIndex]; const refEndpointId = ref.endpointIds[refEndpointIndex]; diff --git a/packages/dbml-core/src/index.ts b/packages/dbml-core/src/index.ts index 4ed75e9a2..1ececf44c 100644 --- a/packages/dbml-core/src/index.ts +++ b/packages/dbml-core/src/index.ts @@ -44,6 +44,13 @@ export { dbmlMonarchTokensProvider, DEFAULT_ENTRY, Filepath, + // Relationship cardinality constants and utilities + CARDINALITY_ONE, + CARDINALITY_MAYBE, + CARDINALITY_SOME, + CARDINALITY_MANY, + getMultiplicities, + getRelationshipOp, } from '@dbml/parse'; // Re-export types @@ -54,4 +61,6 @@ export type { DiagramViewSyncOperation, DiagramViewBlock, TextEdit, + RelationCardinality, + RelationshipOp, } from '@dbml/parse'; diff --git a/packages/dbml-core/src/model_structure/endpoint.js b/packages/dbml-core/src/model_structure/endpoint.js index 7c73b2f3a..6a849b957 100644 --- a/packages/dbml-core/src/model_structure/endpoint.js +++ b/packages/dbml-core/src/model_structure/endpoint.js @@ -1,3 +1,4 @@ +import { CARDINALITY_SOME, CARDINALITY_MANY, CARDINALITY_ONE, CARDINALITY_MAYBE } from '@dbml/parse'; import { DEFAULT_SCHEMA_NAME } from './config'; import Element from './element'; import { shouldPrintSchema, shouldPrintSchemaName } from './utils'; @@ -37,6 +38,13 @@ class Endpoint extends Element { : ''}"${tableName}"`); } this.setFields(fieldNames, table); + + // Adjust cardinality based on field nullability + // Unspecified not_null means nullable (SQL default) + if (this.fields.length > 0 && this.fields.every((f) => !f.not_null && !f.pk)) { + if (this.relation === CARDINALITY_SOME) this.relation = CARDINALITY_MANY; + else if (this.relation === CARDINALITY_ONE) this.relation = CARDINALITY_MAYBE; + } } generateId () { diff --git a/packages/dbml-core/src/model_structure/ref.js b/packages/dbml-core/src/model_structure/ref.js index 8044d2a26..bdb3c9718 100644 --- a/packages/dbml-core/src/model_structure/ref.js +++ b/packages/dbml-core/src/model_structure/ref.js @@ -1,3 +1,4 @@ +import { CARDINALITY_SOME, CARDINALITY_MANY, CARDINALITY_ONE, CARDINALITY_MAYBE } from '@dbml/parse'; import { DEFAULT_SCHEMA_NAME } from './config'; import Element from './element'; import Endpoint from './endpoint'; @@ -66,7 +67,16 @@ class Ref extends Element { if (this.endpoints[0].fields.length !== this.endpoints[1].fields.length) { this.error('Two endpoints have unequal number of fields'); } - // TODO: Handle Error with different number of fields + + // If an endpoint's FK fields are all nullable, both sides become optional + for (const ep of this.endpoints) { + const allNullable = ep.fields.length > 0 + && ep.fields.every((f) => !f.not_null && !f.pk); + if (allNullable) { + if (ep.relation === CARDINALITY_SOME) ep.relation = CARDINALITY_MANY; + else if (ep.relation === CARDINALITY_ONE) ep.relation = CARDINALITY_MAYBE; + } + } } /** diff --git a/packages/dbml-core/types/index.d.ts b/packages/dbml-core/types/index.d.ts index c380ff1ea..e08dcef54 100644 --- a/packages/dbml-core/types/index.d.ts +++ b/packages/dbml-core/types/index.d.ts @@ -42,6 +42,12 @@ export { formatRecordValue, DEFAULT_ENTRY, Filepath, + CARDINALITY_ONE, + CARDINALITY_MAYBE, + CARDINALITY_SOME, + CARDINALITY_MANY, + getMultiplicities, + getRelationshipOp, } from '@dbml/parse'; // Re-export types @@ -52,4 +58,6 @@ export type { DiagramViewSyncOperation, DiagramViewBlock, TextEdit, + RelationCardinality, + RelationshipOp, } from '@dbml/parse'; diff --git a/packages/dbml-core/types/model_structure/endpoint.d.ts b/packages/dbml-core/types/model_structure/endpoint.d.ts index d29855d37..3b3a3a562 100644 --- a/packages/dbml-core/types/model_structure/endpoint.d.ts +++ b/packages/dbml-core/types/model_structure/endpoint.d.ts @@ -4,17 +4,18 @@ import Ref from './ref'; import DbState from './dbState'; import { NormalizedModel } from './database'; import { Token } from './element'; +import type { RelationCardinality } from '@dbml/parse'; export interface RawEndpoint { schemaName: string | null; tableName: string; fieldNames: string[]; - relation: '1' | '*'; + relation: RelationCardinality; token: Token; } declare class Endpoint extends Element { - relation: any; + relation: RelationCardinality; schemaName: string; tableName: string; fieldNames: string[]; @@ -57,7 +58,7 @@ export interface NormalizedEndpoint { tableName: string; fieldNames: string[]; fieldIds: number[]; - relation: string; + relation: RelationCardinality; refId: number; } diff --git a/packages/dbml-core/types/model_structure/field.d.ts b/packages/dbml-core/types/model_structure/field.d.ts index 8e322abaa..a29be4da9 100644 --- a/packages/dbml-core/types/model_structure/field.d.ts +++ b/packages/dbml-core/types/model_structure/field.d.ts @@ -6,12 +6,13 @@ import Enum from './enum'; import Table from './table'; import TablePartial from './tablePartial'; import Check from './check'; +import type { RelationshipOp } from '@dbml/parse'; export interface InlineRef { schemaName: string | null; tableName: string; fieldNames: string[]; - relation: '>' | '<' | '-' | '<>'; + relation: RelationshipOp; token: Token; } diff --git a/packages/dbml-parse/__tests__/examples/interpreter/record/fk.test.ts b/packages/dbml-parse/__tests__/examples/interpreter/record/fk.test.ts index f33d2eb8e..7317ec7dc 100644 --- a/packages/dbml-parse/__tests__/examples/interpreter/record/fk.test.ts +++ b/packages/dbml-parse/__tests__/examples/interpreter/record/fk.test.ts @@ -110,9 +110,13 @@ describe('[example - record] composite foreign key constraints', () => { const result = interpret(source); const warnings = result.getWarnings(); - expect(warnings.length).toBe(2); + expect(warnings.length).toBe(4); + // orders → merchants: (1, "UK") doesn't exist in merchants expect(warnings[0].diagnostic).toBe('FK violation: (orders.merchant_id, orders.country) = (1, "UK") does not exist in (merchants.id, merchants.country_code)'); expect(warnings[1].diagnostic).toBe('FK violation: (orders.merchant_id, orders.country) = (1, "UK") does not exist in (merchants.id, merchants.country_code)'); + // merchants → orders: (2, "UK") doesn't exist in orders + expect(warnings[2].diagnostic).toBe('FK violation: (merchants.id, merchants.country_code) = (2, "UK") does not exist in (orders.merchant_id, orders.country)'); + expect(warnings[3].diagnostic).toBe('FK violation: (merchants.id, merchants.country_code) = (2, "UK") does not exist in (orders.merchant_id, orders.country)'); }); test('should allow NULL in composite FK columns', () => { @@ -131,7 +135,7 @@ describe('[example - record] composite foreign key constraints', () => { country varchar status varchar } - Ref: orders.(merchant_id, country) > merchants.(id, country_code) + Ref: orders.(merchant_id, country) >? merchants.(id, country_code) records merchants(id, country_code) { 1, "US" @@ -244,9 +248,11 @@ describe('[example - record] composite foreign key constraints', () => { const result = interpret(source); const warnings = result.getWarnings(); - expect(warnings.length).toBe(2); + expect(warnings.length).toBe(4); expect(warnings[0].diagnostic).toBe('FK violation: (posts.user_id, posts.tenant_id) = (999, 100) does not exist in (auth.users.id, auth.users.tenant_id)'); expect(warnings[1].diagnostic).toBe('FK violation: (posts.user_id, posts.tenant_id) = (999, 100) does not exist in (auth.users.id, auth.users.tenant_id)'); + expect(warnings[2].diagnostic).toBe('FK violation: (auth.users.id, auth.users.tenant_id) = (2, 100) does not exist in (posts.user_id, posts.tenant_id)'); + expect(warnings[3].diagnostic).toBe('FK violation: (auth.users.id, auth.users.tenant_id) = (2, 100) does not exist in (posts.user_id, posts.tenant_id)'); }); }); @@ -442,7 +448,7 @@ describe('[example - record] simple foreign key constraints', () => { category_id int name varchar } - Ref: products.category_id > categories.id + Ref: products.category_id >? categories.id records categories(id, name) { 1, "Electronics" @@ -495,7 +501,7 @@ describe('[example - record] simple foreign key constraints', () => { } Table user_profiles { id int [pk] - user_id int + user_id int [unique] bio text } Table departments { @@ -556,7 +562,7 @@ describe('[example - record] simple foreign key constraints', () => { manager_id int name varchar } - Ref: employees.manager_id > employees.id + Ref: employees.manager_id >? employees.id records users(id, name) { 1, "Alice" @@ -574,9 +580,12 @@ describe('[example - record] simple foreign key constraints', () => { const result = interpret(source); const warnings = result.getWarnings(); - expect(warnings.length).toBe(2); + expect(warnings.length).toBe(4); expect(warnings[0].diagnostic).toBe('FK violation: posts.user_id = 999 does not exist in users.id'); expect(warnings[1].diagnostic).toBe('FK violation: employees.manager_id = 999 does not exist in employees.id'); + // Reverse: employees.id must exist in employees.manager_id + expect(warnings[2].diagnostic).toBe('FK violation: employees.id = 2 does not exist in employees.manager_id'); + expect(warnings[3].diagnostic).toBe('FK violation: employees.id = 3 does not exist in employees.manager_id'); }); test('should detect FK violation when target table is empty', () => { @@ -743,15 +752,17 @@ describe('[example - record] FK in table partials', () => { const result = interpret(source); const warnings = result.getWarnings(); - expect(warnings.length).toBe(1); + expect(warnings.length).toBe(2); expect(warnings[0].code).toBe(CompileErrorCode.INVALID_RECORDS_FIELD); expect(warnings[0].diagnostic).toBe('FK violation: comments.created_by = 999 does not exist in users.id'); + // Reverse: users.id=2 not in comments.created_by + expect(warnings[1].diagnostic).toBe('FK violation: users.id = 2 does not exist in comments.created_by'); }); test('should allow NULL FK values from injected table partial', () => { const source = ` TablePartial optional_user { - user_id int [ref: > users.id] + user_id int [ref: >? users.id] } Table users { @@ -835,7 +846,7 @@ describe('[example - record] FK in table partials', () => { test('should validate self-referencing FK from injected table partial', () => { const source = ` TablePartial hierarchical { - parent_id int [ref: > nodes.id] + parent_id int [ref: >? nodes.id] } Table nodes { @@ -853,8 +864,11 @@ describe('[example - record] FK in table partials', () => { const result = interpret(source); const warnings = result.getWarnings(); - expect(warnings.length).toBe(1); + expect(warnings.length).toBe(3); expect(warnings[0].code).toBe(CompileErrorCode.INVALID_RECORDS_FIELD); expect(warnings[0].diagnostic).toBe('FK violation: nodes.parent_id = 999 does not exist in nodes.id'); + // Reverse: nodes.id must exist in nodes.parent_id + expect(warnings[1].diagnostic).toBe('FK violation: nodes.id = 2 does not exist in nodes.parent_id'); + expect(warnings[2].diagnostic).toBe('FK violation: nodes.id = 3 does not exist in nodes.parent_id'); }); }); diff --git a/packages/dbml-parse/__tests__/examples/lexer/optional_ref.test.ts b/packages/dbml-parse/__tests__/examples/lexer/optional_ref.test.ts new file mode 100644 index 000000000..4b966dd9c --- /dev/null +++ b/packages/dbml-parse/__tests__/examples/lexer/optional_ref.test.ts @@ -0,0 +1,48 @@ +import { + describe, expect, test, +} from 'vitest'; +import { + SyntaxTokenKind, isTriviaToken, +} from '@/core/types/tokens'; +import { + lex, +} from '@tests/utils'; + +function getTokens (source: string) { + return lex(source).getValue().filter((t) => !isTriviaToken(t) && t.kind !== SyntaxTokenKind.EOF); +} + +describe('[example] optional ref operators', () => { + test.each([ + '-', '-?', '?-', '?-?', + '>', '>?', '?>', '?>?', + '<', '', '<>?', '?<>', '?<>?', + ])('should tokenize %s as a single operator', (op) => { + const tokens = getTokens(op); + + expect(tokens).toHaveLength(1); + expect(tokens[0].kind).toBe(SyntaxTokenKind.OP); + expect(tokens[0].value).toBe(op); + }); + + test('should tokenize all optional ref operators in sequence', () => { + const source = '- -? ?- ?-? > >? ?> ?>? < <>? ?<> ?<>?'; + const tokens = getTokens(source); + + expect(tokens).toHaveLength(16); + expect(tokens.map((t) => t.value)).toEqual([ + '-', '-?', '?-', '?-?', + '>', '>?', '?>', '?>?', + '<', '', '<>?', '?<>', '?<>?', + ]); + }); + + test('should not produce errors for optional ref operators', () => { + const source = '-? ?- ?-? >? ?> ?>? ? ?<> ?<>?'; + const result = lex(source); + + expect(result.getErrors()).toHaveLength(0); + }); +}); diff --git a/packages/dbml-parse/__tests__/examples/parser/optional_ref.test.ts b/packages/dbml-parse/__tests__/examples/parser/optional_ref.test.ts new file mode 100644 index 000000000..662718ab1 --- /dev/null +++ b/packages/dbml-parse/__tests__/examples/parser/optional_ref.test.ts @@ -0,0 +1,39 @@ +import { + describe, expect, test, +} from 'vitest'; +import { + parse, +} from '@tests/utils'; + +describe('[example] optional ref parsing', () => { + test.each([ + '-', '-?', '?-', '?-?', + '>', '>?', '?>', '?>?', + '<', '', '<>?', '?<>', '?<>?', + ])('should parse standalone ref with operator %s without errors', (op) => { + const source = ` + Table users { id int } + Table posts { user_id int } + Ref: posts.user_id ${op} users.id + `; + const result = parse(source); + expect(result.getErrors()).toHaveLength(0); + }); + + test.each([ + '>', '>?', '?>', '?>?', + '<', '', '<>?', '?<>', '?<>?', + ])('should parse inline ref with operator %s without errors', (op) => { + const source = ` + Table users { id int } + Table posts { + user_id int [ref: ${op} users.id] + } + `; + const result = parse(source); + expect(result.getErrors()).toHaveLength(0); + }); +}); diff --git a/packages/dbml-parse/__tests__/examples/services/code_actions/code_actions.test.ts b/packages/dbml-parse/__tests__/examples/services/code_actions/code_actions.test.ts new file mode 100644 index 000000000..c33a6ea92 --- /dev/null +++ b/packages/dbml-parse/__tests__/examples/services/code_actions/code_actions.test.ts @@ -0,0 +1,94 @@ +import { + describe, expect, test, +} from 'vitest'; +import { interpret } from '@tests/utils'; + +function getQuickFixes (source: string) { + const result = interpret(source); + const infos = result.getInfos(); + return infos.flatMap((info) => info.quickFixes ?? []); +} + +describe('[example] code actions - ref constraint quick fixes', () => { + describe('nullability fixes', () => { + test('nullable column with required ref suggests changing op or adding [not null]', () => { + const source = ` + Table users { id int [pk] } + Table posts { user_id int [null] } + Ref: posts.user_id > users.id + `; + const fixes = getQuickFixes(source); + expect(fixes.some((f) => f.title.includes('Change operator'))).toBe(true); + expect(fixes.some((f) => f.title.includes('not null'))).toBe(true); + }); + + test('NOT NULL column with optional ref suggests changing op', () => { + const source = ` + Table users { id int [pk] } + Table posts { user_id int [not null] } + Ref: posts.user_id >? users.id + `; + const fixes = getQuickFixes(source); + expect(fixes.some((f) => f.title.includes('Change operator'))).toBe(true); + }); + + test('no fixes when nullability matches operator', () => { + const source = ` + Table users { id int [pk] } + Table posts { user_id int [not null] } + Ref: posts.user_id > users.id + `; + const fixes = getQuickFixes(source); + expect(fixes).toHaveLength(0); + }); + }); + + describe('uniqueness fixes', () => { + test('non-unique column in one-to-one ref suggests changing op or adding [unique]', () => { + const source = ` + Table users { id int [pk] } + Table profiles { user_id int } + Ref: profiles.user_id - users.id + `; + const fixes = getQuickFixes(source); + expect(fixes.some((f) => f.title.includes('Change operator'))).toBe(true); + expect(fixes.some((f) => f.title.includes('unique'))).toBe(true); + }); + + test('no uniqueness fix when column is pk', () => { + const source = ` + Table users { id int [pk] } + Table profiles { user_id int [pk] } + Ref: profiles.user_id - users.id + `; + const fixes = getQuickFixes(source); + const uniqueFixes = fixes.filter((f) => f.title.includes('unique')); + expect(uniqueFixes).toHaveLength(0); + }); + }); + + describe('operator change produces correct new op', () => { + test('> with nullable column suggests >?', () => { + const source = ` + Table users { id int [pk] } + Table posts { user_id int [null] } + Ref: posts.user_id > users.id + `; + const fixes = getQuickFixes(source); + const opFix = fixes.find((f) => f.title.includes('Change operator')); + expect(opFix?.title).toContain('>?'); + }); + + test('- with non-unique column suggests >', () => { + const source = ` + Table users { id int [pk] } + Table profiles { user_id int [not null] } + Ref: profiles.user_id - users.id + `; + const fixes = getQuickFixes(source); + const opFix = fixes.find((f) => f.title.includes('Change operator')); + // left card (1,1) max becomes *, getRelationshipOp(*, 1) = > + expect(opFix?.title).toContain('>'); + }); + }); +}); diff --git a/packages/dbml-parse/__tests__/examples/validator/optional_ref.test.ts b/packages/dbml-parse/__tests__/examples/validator/optional_ref.test.ts new file mode 100644 index 000000000..278bc4d14 --- /dev/null +++ b/packages/dbml-parse/__tests__/examples/validator/optional_ref.test.ts @@ -0,0 +1,39 @@ +import { + describe, expect, test, +} from 'vitest'; +import { + analyze, +} from '@tests/utils'; + +describe('[example] optional ref validation', () => { + test.each([ + '-', '-?', '?-', '?-?', + '>', '>?', '?>', '?>?', + '<', '', '<>?', '?<>', '?<>?', + ])('should accept standalone ref with operator %s', (op) => { + const source = ` + Table users { id int } + Table posts { user_id int } + Ref: posts.user_id ${op} users.id + `; + const errors = analyze(source).getErrors(); + expect(errors).toHaveLength(0); + }); + + test.each([ + '>', '>?', '?>', '?>?', + '<', '', '<>?', '?<>', '?<>?', + ])('should accept inline ref with operator %s', (op) => { + const source = ` + Table users { id int } + Table posts { + user_id int [ref: ${op} users.id] + } + `; + const errors = analyze(source).getErrors(); + expect(errors).toHaveLength(0); + }); +}); diff --git a/packages/dbml-parse/__tests__/snapshots/binder/binder.test.ts b/packages/dbml-parse/__tests__/snapshots/binder/binder.test.ts index 97d628c96..75f1bbdd2 100644 --- a/packages/dbml-parse/__tests__/snapshots/binder/binder.test.ts +++ b/packages/dbml-parse/__tests__/snapshots/binder/binder.test.ts @@ -11,8 +11,10 @@ import { SymbolKind, SchemaSymbol } from '@/core/types/symbol'; import type { NodeSymbol } from '@/core/types/symbol'; function serializeBinderResult (compiler: Compiler, ast: ProgramNode): string { - const errors = compiler.parse.errors(DEFAULT_ENTRY); - const warnings = compiler.parse.warnings(DEFAULT_ENTRY); + const report = compiler.interpretFile(DEFAULT_ENTRY); + const errors = report.getErrors(); + const warnings = report.getWarnings(); + const infos = report.getInfos(); const nodeReferees = collectNodesWithReferee(compiler, ast); // FIXME: this snapshot manually splits the program's symbol table into @@ -46,6 +48,7 @@ function serializeBinderResult (compiler: Compiler, ast: ProgramNode): string { nodeReferees, errors, warnings, + infos, }), null, 2); } diff --git a/packages/dbml-parse/__tests__/snapshots/binder/output/ref.out.json b/packages/dbml-parse/__tests__/snapshots/binder/output/ref.out.json index 30e19408e..220cabe16 100644 --- a/packages/dbml-parse/__tests__/snapshots/binder/output/ref.out.json +++ b/packages/dbml-parse/__tests__/snapshots/binder/output/ref.out.json @@ -1,4 +1,78 @@ { + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L1:C4, L1:C14]", + "snippet": "id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'referrer_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@referrer_id@[L2:C4, L2:C23]", + "snippet": "referrer_id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'referrer_id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@referrer_id@[L2:C4, L2:C23]", + "snippet": "referrer_id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L6:C4, L6:C12]", + "snippet": "Users.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'referrer_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L6:C15, L6:C32]", + "snippet": "Users.referrer_id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'referrer_id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L6:C15, L6:C32]", + "snippet": "Users.referrer_id" + } + } + } + ], "nodeReferees": [ { "context": { diff --git a/packages/dbml-parse/__tests__/snapshots/binder/output/ref_name_and_color_setting.out.json b/packages/dbml-parse/__tests__/snapshots/binder/output/ref_name_and_color_setting.out.json index 7c576a3e0..8b4637f6f 100644 --- a/packages/dbml-parse/__tests__/snapshots/binder/output/ref_name_and_color_setting.out.json +++ b/packages/dbml-parse/__tests__/snapshots/binder/output/ref_name_and_color_setting.out.json @@ -1,4 +1,150 @@ { + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L1:C2, L1:C8]", + "snippet": "id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L1:C2, L1:C8]", + "snippet": "id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'c_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@c_id@[L2:C2, L2:C10]", + "snippet": "c_id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L6:C2, L6:C8]", + "snippet": "id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L6:C2, L6:C8]", + "snippet": "id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L6:C2, L6:C8]", + "snippet": "id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L11:C15, L11:C19]", + "snippet": "b.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L11:C15, L11:C19]", + "snippet": "b.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L11:C22, L11:C26]", + "snippet": "c.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L15:C2, L15:C6]", + "snippet": "c.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L15:C2, L15:C6]", + "snippet": "c.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'c_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L15:C9, L15:C15]", + "snippet": "b.c_id" + } + } + } + ], "nodeReferees": [ { "context": { diff --git a/packages/dbml-parse/__tests__/snapshots/binder/output/ref_setting.out.json b/packages/dbml-parse/__tests__/snapshots/binder/output/ref_setting.out.json index 90764371f..1f18e28a3 100644 --- a/packages/dbml-parse/__tests__/snapshots/binder/output/ref_setting.out.json +++ b/packages/dbml-parse/__tests__/snapshots/binder/output/ref_setting.out.json @@ -1,4 +1,102 @@ { + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L1:C1, L1:C11]", + "snippet": "id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L1:C1, L1:C11]", + "snippet": "id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'referrer' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@referrer@[L4:C4, L4:C31]", + "snippet": "referrer i...[ref: -id]" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'referrer' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@referrer@[L4:C4, L4:C31]", + "snippet": "referrer i...[ref: -id]" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'referrer' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@referrer@[L4:C4, L4:C31]", + "snippet": "referrer i...[ref: -id]" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'referrer' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@referrer@[L4:C4, L4:C31]", + "snippet": "referrer i...[ref: -id]" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L4:C22, L4:C30]", + "snippet": "ref: -id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L4:C22, L4:C30]", + "snippet": "ref: -id" + } + } + } + ], "nodeReferees": [ { "context": { diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/interpreter.test.ts b/packages/dbml-parse/__tests__/snapshots/interpreter/interpreter.test.ts index ffa38e476..a6c3b36aa 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/interpreter.test.ts +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/interpreter.test.ts @@ -17,11 +17,13 @@ import type Report from '@/core/types/report'; function serializeInterpreterResult (compiler: Compiler, report: Report): string { const errors = report.getErrors(); const warnings = report.getWarnings(); + const infos = report.getInfos(); const value = errors.length > 0 ? undefined : report.getValue(); return JSON.stringify(toSnapshot(compiler, { database: value as any, errors, warnings, + infos, }), null, 2); } diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/output/general_schema.out.json b/packages/dbml-parse/__tests__/snapshots/interpreter/output/general_schema.out.json index 4a480a447..6d763e490 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/output/general_schema.out.json +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/output/general_schema.out.json @@ -1341,5 +1341,127 @@ "offset": 0 } } - } + }, + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'order_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@order_id@[L20:C2, L20:C16]", + "snippet": "\"order_id\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'product_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@product_id@[L21:C2, L21:C18]", + "snippet": "\"product_id\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'country_code' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@country_code@[L47:C2, L47:C20]", + "snippet": "\"country_code\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'country_code' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@country_code@[L63:C2, L63:C20]", + "snippet": "\"country_code\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'admin_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@admin_id@[L65:C2, L65:C16]", + "snippet": "\"admin_id\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'order_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L74:C20, L74:C44]", + "snippet": "\"order_ite...\"order_id\"" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'product_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L76:C22, L76:C48]", + "snippet": "\"order_ite...roduct_id\"" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'country_code' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L78:C25, L78:C47]", + "snippet": "\"users\".\"c...ntry_code\"" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'country_code' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L80:C25, L80:C51]", + "snippet": "\"merchants...ntry_code\"" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'admin_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L84:C19, L84:C41]", + "snippet": "\"merchants...\"admin_id\"" + } + } + } + ] } \ No newline at end of file diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/output/note_normalize.out.json b/packages/dbml-parse/__tests__/snapshots/interpreter/output/note_normalize.out.json index eef8e4f8f..43524d38b 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/output/note_normalize.out.json +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/output/note_normalize.out.json @@ -579,5 +579,79 @@ "offset": 87 } } - } + }, + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'following_user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@following_user_id@[L4:C2, L4:C27]", + "snippet": "following_...id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'followed_user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@followed_user_id@[L5:C2, L5:C26]", + "snippet": "followed_u...id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@user_id@[L45:C2, L45:C17]", + "snippet": "user_id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L60:C5, L60:C18]", + "snippet": "posts.user_id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'following_user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L62:C16, L62:C41]", + "snippet": "follows.fo...ng_user_id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'followed_user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L64:C16, L64:C40]", + "snippet": "follows.fo...ed_user_id" + } + } + } + ] } \ No newline at end of file diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/output/note_normalize_with_top_empty_lines.out.json b/packages/dbml-parse/__tests__/snapshots/interpreter/output/note_normalize_with_top_empty_lines.out.json index b88a3db0e..39c81f2c5 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/output/note_normalize_with_top_empty_lines.out.json +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/output/note_normalize_with_top_empty_lines.out.json @@ -579,5 +579,79 @@ "offset": 87 } } - } + }, + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'following_user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@following_user_id@[L4:C2, L4:C27]", + "snippet": "following_...id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'followed_user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@followed_user_id@[L5:C2, L5:C26]", + "snippet": "followed_u...id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@user_id@[L50:C2, L50:C17]", + "snippet": "user_id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L65:C5, L65:C18]", + "snippet": "posts.user_id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'following_user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L67:C16, L67:C41]", + "snippet": "follows.fo...ng_user_id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'followed_user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L69:C16, L69:C40]", + "snippet": "follows.fo...ed_user_id" + } + } + } + ] } \ No newline at end of file diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/output/project.out.json b/packages/dbml-parse/__tests__/snapshots/interpreter/output/project.out.json index ecd2278c2..cc13de31d 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/output/project.out.json +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/output/project.out.json @@ -1374,5 +1374,127 @@ "offset": 0 } } - } + }, + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'order_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@order_id@[L31:C2, L31:C16]", + "snippet": "\"order_id\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'product_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@product_id@[L32:C2, L32:C18]", + "snippet": "\"product_id\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'country_code' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@country_code@[L58:C2, L58:C20]", + "snippet": "\"country_code\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'country_code' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@country_code@[L74:C2, L74:C20]", + "snippet": "\"country_code\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'admin_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@admin_id@[L76:C2, L76:C16]", + "snippet": "\"admin_id\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'order_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L85:C20, L85:C44]", + "snippet": "\"order_ite...\"order_id\"" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'product_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L87:C22, L87:C48]", + "snippet": "\"order_ite...roduct_id\"" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'country_code' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L89:C25, L89:C47]", + "snippet": "\"users\".\"c...ntry_code\"" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'country_code' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L91:C25, L91:C51]", + "snippet": "\"merchants...ntry_code\"" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'admin_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L95:C19, L95:C41]", + "snippet": "\"merchants...\"admin_id\"" + } + } + } + ] } \ No newline at end of file diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/output/ref_name_and_color_setting.out.json b/packages/dbml-parse/__tests__/snapshots/interpreter/output/ref_name_and_color_setting.out.json index c54ce7e58..a34645e70 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/output/ref_name_and_color_setting.out.json +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/output/ref_name_and_color_setting.out.json @@ -256,5 +256,151 @@ "offset": 0 } } - } + }, + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L1:C2, L1:C8]", + "snippet": "id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L1:C2, L1:C8]", + "snippet": "id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'c_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@c_id@[L2:C2, L2:C10]", + "snippet": "c_id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L6:C2, L6:C8]", + "snippet": "id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L6:C2, L6:C8]", + "snippet": "id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L6:C2, L6:C8]", + "snippet": "id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L11:C15, L11:C19]", + "snippet": "b.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L11:C15, L11:C19]", + "snippet": "b.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L11:C22, L11:C26]", + "snippet": "c.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L15:C2, L15:C6]", + "snippet": "c.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L15:C2, L15:C6]", + "snippet": "c.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'c_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L15:C9, L15:C15]", + "snippet": "b.c_id" + } + } + } + ] } \ No newline at end of file diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/output/ref_settings.out.json b/packages/dbml-parse/__tests__/snapshots/interpreter/output/ref_settings.out.json index a9ff067dc..d53fe4f78 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/output/ref_settings.out.json +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/output/ref_settings.out.json @@ -255,5 +255,151 @@ "offset": 0 } } - } + }, + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L1:C4, L1:C14]", + "snippet": "id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'code' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@code@[L2:C4, L2:C15]", + "snippet": "code number" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L6:C4, L6:C14]", + "snippet": "id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L6:C4, L6:C14]", + "snippet": "id integer" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'code' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@code@[L7:C4, L7:C15]", + "snippet": "code number" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'code' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@code@[L7:C4, L7:C15]", + "snippet": "code number" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L10:C5, L10:C9]", + "snippet": "A.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L10:C12, L10:C16]", + "snippet": "B.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L10:C12, L10:C16]", + "snippet": "B.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'code' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L11:C5, L11:C11]", + "snippet": "A.code" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'code' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L11:C14, L11:C20]", + "snippet": "B.code" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'code' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L11:C14, L11:C20]", + "snippet": "B.code" + } + } + } + ] } \ No newline at end of file diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/output/referential_actions.out.json b/packages/dbml-parse/__tests__/snapshots/interpreter/output/referential_actions.out.json index be795b8a3..aa2f38902 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/output/referential_actions.out.json +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/output/referential_actions.out.json @@ -922,5 +922,127 @@ "offset": 0 } } - } + }, + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'name' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@name@[L5:C2, L5:C21]", + "snippet": "\"name\" varchar(255)" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'order_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@order_id@[L16:C2, L16:C16]", + "snippet": "\"order_id\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'product_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@product_id@[L17:C2, L17:C18]", + "snippet": "\"product_id\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'product_name' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@product_name@[L18:C2, L18:C29]", + "snippet": "\"product_n...rchar(255)" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L23:C2, L23:C10]", + "snippet": "\"id\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'order_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L50:C20, L50:C44]", + "snippet": "\"order_ite...\"order_id\"" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L52:C4, L52:C29]", + "snippet": "\"products\"...\", \"name\")" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'name' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L52:C4, L52:C29]", + "snippet": "\"products\"...\", \"name\")" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'product_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L52:C32, L52:C76]", + "snippet": "\"order_ite...uct_name\")" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'product_name' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L52:C32, L52:C76]", + "snippet": "\"order_ite...uct_name\")" + } + } + } + ] } \ No newline at end of file diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/output/table_group.out.json b/packages/dbml-parse/__tests__/snapshots/interpreter/output/table_group.out.json index 85fa04c64..fc315f819 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/output/table_group.out.json +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/output/table_group.out.json @@ -357,5 +357,79 @@ "offset": 0 } } - } + }, + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L1:C2, L1:C8]", + "snippet": "id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@id@[L1:C2, L1:C8]", + "snippet": "id int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'admin_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@admin_id@[L12:C2, L12:C28]", + "snippet": "admin_id i...f: > U.id]" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'admin_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@admin_id@[L12:C2, L12:C28]", + "snippet": "admin_id i...f: > U.id]" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L12:C16, L12:C27]", + "snippet": "ref: > U.id" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'id' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L12:C16, L12:C27]", + "snippet": "ref: > U.id" + } + } + } + ] } \ No newline at end of file diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/output/table_settings.out.json b/packages/dbml-parse/__tests__/snapshots/interpreter/output/table_settings.out.json index a9e5a6c58..9b5fd8c1a 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/output/table_settings.out.json +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/output/table_settings.out.json @@ -487,5 +487,55 @@ "offset": 0 } } - } + }, + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@user_id@[L18:C2, L18:C15]", + "snippet": "\"user_id\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'product_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@product_id@[L19:C2, L19:C18]", + "snippet": "\"product_id\" int" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'user_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L23:C18, L23:C38]", + "snippet": "\"merchant\".\"user_id\"" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'product_id' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L25:C21, L25:C44]", + "snippet": "\"merchant\"...roduct_id\"" + } + } + } + ] } \ No newline at end of file diff --git a/packages/dbml-parse/__tests__/snapshots/interpreter/output/tablepartial_causing_circular_ref.out.json b/packages/dbml-parse/__tests__/snapshots/interpreter/output/tablepartial_causing_circular_ref.out.json index f5df3db28..e45ea93d7 100644 --- a/packages/dbml-parse/__tests__/snapshots/interpreter/output/tablepartial_causing_circular_ref.out.json +++ b/packages/dbml-parse/__tests__/snapshots/interpreter/output/tablepartial_causing_circular_ref.out.json @@ -256,5 +256,79 @@ "offset": 0 } } - } + }, + "infos": [ + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'col3' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@col3@[L2:C2, L2:C27]", + "snippet": "col3 type ... > T.col2]" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'col3' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@col3@[L2:C2, L2:C27]", + "snippet": "col3 type ... > T.col2]" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'col2' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@col2@[L7:C2, L7:C25]", + "snippet": "col2 type ...f: > col3]" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'col2' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@col2@[L7:C2, L7:C25]", + "snippet": "col2 type ...f: > col3]" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'col3' is nullable but operator implies mandatory", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L7:C13, L7:C24]", + "snippet": "ref: > col3" + } + } + }, + { + "code": "INVALID_REF_RELATIONSHIP", + "diagnostic": "Column 'col3' should be unique or primary key for a one-side relationship", + "filepath": "/main.dbml", + "level": "info", + "node": { + "context": { + "id": "node@@@[L7:C13, L7:C24]", + "snippet": "ref: > col3" + } + } + } + ] } \ No newline at end of file diff --git a/packages/dbml-parse/__tests__/utils/compiler.ts b/packages/dbml-parse/__tests__/utils/compiler.ts index 5f3fba168..1a42a49b1 100644 --- a/packages/dbml-parse/__tests__/utils/compiler.ts +++ b/packages/dbml-parse/__tests__/utils/compiler.ts @@ -103,6 +103,7 @@ export function interpret (source: string): Report | undefine db ? db as Database : undefined, [...parseResult.getErrors(), ...bindResult.getErrors(), ...interpretResult.getErrors()], [...parseResult.getWarnings(), ...bindResult.getWarnings(), ...interpretResult.getWarnings()], + [...parseResult.getInfos(), ...bindResult.getInfos(), ...interpretResult.getInfos()], ); } diff --git a/packages/dbml-parse/__tests__/utils/testHelpers.ts b/packages/dbml-parse/__tests__/utils/testHelpers.ts index ac5fc22cd..10eba8782 100644 --- a/packages/dbml-parse/__tests__/utils/testHelpers.ts +++ b/packages/dbml-parse/__tests__/utils/testHelpers.ts @@ -3,7 +3,7 @@ import { NodeSymbol, SchemaSymbol, SymbolKind } from '@/core/types/symbol'; import { SyntaxToken } from '@/core/types/tokens'; import { ElementDeclarationNode, FunctionApplicationNode, FunctionExpressionNode, LiteralNode, PrimaryExpressionNode, ProgramNode, SyntaxNode, VariableNode } from '@/core/types/nodes'; import { getElementNameString } from '@/core/utils/expression'; -import { CompileError, CompileErrorCode, CompileWarning } from '@/core/types/errors'; +import { CompileError, CompileErrorCode, CompileWarning, CompileInfo } from '@/core/types/errors'; import type Compiler from '@/compiler'; import { UNHANDLED } from '@/core/types/module'; import { Filepath, SchemaElement, TokenPosition } from '@/core/types'; @@ -78,6 +78,7 @@ export type Snappable = | string | number | null | undefined | boolean | bigint | symbol | CompileWarning | CompileError + | CompileInfo | SyntaxNode | SyntaxToken | NodeSymbol @@ -112,8 +113,9 @@ function sortArray (array: unknown[]): unknown[] { if (typeof s === 'boolean') return 2; if (typeof s === 'bigint') return 3; if (typeof s === 'symbol') return 4; - if (s instanceof CompileWarning) return 5; - if (s instanceof CompileError) return 6; + if (s instanceof CompileInfo) return 5; + if (s instanceof CompileWarning) return 6; + if (s instanceof CompileError) return 7; if (s instanceof SyntaxNode) return 7; if (s instanceof SyntaxToken) return 8; if ((s as any)?.token) return 9; // possibly a schema element @@ -127,7 +129,7 @@ function sortArray (array: unknown[]): unknown[] { if (typeof s === 'boolean') return Number(s); if (typeof s === 'bigint') return Number(s); if (typeof s === 'symbol') return s.toString(); - if (s instanceof CompileWarning || s instanceof CompileError) return (s as any).nodeOrToken?.start ?? 0; + if (s instanceof CompileInfo || s instanceof CompileWarning || s instanceof CompileError) return (s as any).nodeOrToken?.start ?? 0; if (s instanceof SyntaxNode) return s.start; if (s instanceof SyntaxToken) return s.start; if ((s as any)?.declaration) return getIntraKindRank((s as any).declaration); @@ -144,7 +146,7 @@ function sortArray (array: unknown[]): unknown[] { // Secondary tiebreaker when primary rank is equal function getTiebreakerRank (s: unknown): number | string { - if (s instanceof CompileWarning || s instanceof CompileError) return s.diagnostic; + if (s instanceof CompileInfo || s instanceof CompileWarning || s instanceof CompileError) return s.diagnostic; if (s instanceof SyntaxNode) return s.id; if (s instanceof SyntaxToken) return s.value ?? ''; if ((s as any)?.id !== undefined) return (s as any).id; @@ -171,6 +173,11 @@ export function toSnapshot ( if (Array.isArray(value)) { return sortArray([...value]).map((v) => toSnapshot(compiler, v as Snappable, { simple, includeReferences, includeSymbols, includeReferee })); } + if (value instanceof CompileInfo) { + return infoToSnapshot(compiler, value, { + simple, + }); + } if (value instanceof CompileWarning) { return warningToSnapshot(compiler, value, { simple, @@ -251,6 +258,46 @@ export function errorToSnapshot ( }); } +export function infoToSnapshot ( + compiler: Compiler, + info: CompileInfo, + { + simple = false, + }: { simple?: boolean } = {}, +): unknown { + const { + code, + diagnostic, + nodeOrToken, + filepath, + } = info; + if (simple) { + return sortObject({ + level: 'info', + code: CompileErrorCode[code], + diagnostic, + filepath: filepath.toString(), + }); + } + return sortObject({ + level: 'info', + code: CompileErrorCode[code], + diagnostic, + filepath: filepath.toString(), + ...(nodeOrToken instanceof SyntaxNode + ? { + node: syntaxNodeToSnapshot(compiler, nodeOrToken, { + simple: true, + }), + } + : { + token: syntaxTokenToSnapshot(compiler, nodeOrToken as SyntaxToken, { + simple: true, + }), + }), + }); +} + export function warningToSnapshot ( compiler: Compiler, warning: CompileWarning, diff --git a/packages/dbml-parse/eslint.config.ts b/packages/dbml-parse/eslint.config.ts index fd13835b0..8562511f6 100644 --- a/packages/dbml-parse/eslint.config.ts +++ b/packages/dbml-parse/eslint.config.ts @@ -18,6 +18,7 @@ export default defineConfig( ignores: [ 'node_modules/*', 'dist/*', + 'dist-profile/*', 'vite.config.ts', 'vite.profile.config.ts', 'eslint.config.ts', diff --git a/packages/dbml-parse/package.json b/packages/dbml-parse/package.json index 806e5dcc4..21e190874 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": "8.3.1", + "version": "9.0.0-optional-ref.0", "description": "DBML parser v2", "author": "Holistics ", "license": "Apache-2.0", diff --git a/packages/dbml-parse/src/compiler/index.ts b/packages/dbml-parse/src/compiler/index.ts index df2dfead3..24fbba417 100644 --- a/packages/dbml-parse/src/compiler/index.ts +++ b/packages/dbml-parse/src/compiler/index.ts @@ -18,7 +18,7 @@ import { type Internable, type Primitive, intern } from '@/core/types/internable import { SyntaxNode, SyntaxNodeIdGenerator } from '@/core/types/nodes'; import { NodeSymbolIdGenerator, SymbolFactory } from '@/core/types/symbol'; import { - DBMLCompletionItemProvider, DBMLDefinitionProvider, DBMLDiagnosticsProvider, DBMLReferencesProvider, + DBMLCodeActionProvider, DBMLCompletionItemProvider, DBMLDefinitionProvider, DBMLDiagnosticsProvider, DBMLReferencesProvider, } from '@/services/index'; import { type DbmlProjectLayout } from './projectLayout'; import { @@ -46,7 +46,7 @@ import { symbolUses } from './queries/symbol/symbolUses'; import { type DiagramViewBlock, findDiagramViewBlocks, - renameTable, syncDiagramView, + renameTable, syncDiagramView, addSetting, } from './queries/transform'; import { addDoubleQuoteIfNeeded, escapeString, formatRecordValue, isValidIdentifier, splitQualifiedIdentifier, unescapeString, @@ -388,6 +388,7 @@ export default class Compiler { // transform queries renameTable = renameTable.bind(this); syncDiagramView = syncDiagramView.bind(this); + addSetting = addSetting.bind(this); findDiagramViewBlocks (filepath: Filepath): DiagramViewBlock[] { return findDiagramViewBlocks(this.getSource(filepath) ?? ''); } @@ -432,6 +433,7 @@ export default class Compiler { triggerCharacters, }), diagnosticsProvider: new DBMLDiagnosticsProvider(this), + codeActionProvider: new DBMLCodeActionProvider(this), }; } } diff --git a/packages/dbml-parse/src/compiler/queries/pipeline/interpret.ts b/packages/dbml-parse/src/compiler/queries/pipeline/interpret.ts index db25f642a..9d709a800 100644 --- a/packages/dbml-parse/src/compiler/queries/pipeline/interpret.ts +++ b/packages/dbml-parse/src/compiler/queries/pipeline/interpret.ts @@ -1,6 +1,6 @@ import type Compiler from '@/compiler'; import type { Database, MasterDatabase } from '@/core/types/schemaJson'; -import type { CompileError, CompileWarning } from '@/core/types/errors'; +import type { CompileError, CompileWarning, CompileInfo } from '@/core/types/errors'; import { Filepath, type FilepathId } from '@/core/types/filepath'; import { UNHANDLED } from '@/core/types/module'; import Report from '@/core/types/report'; @@ -18,6 +18,7 @@ export function interpretFile (this: Compiler, filepath: Filepath): Report { const errors: CompileError[] = []; const warnings: CompileWarning[] = []; + const infos: CompileInfo[] = []; // Collect all reachable files from all entry points const visited = new Set(); @@ -37,10 +38,12 @@ export function interpretProject (this: Compiler): Report { const parseResult = this.parseFile(file); errors.push(...parseResult.getErrors()); warnings.push(...parseResult.getWarnings()); + infos.push(...parseResult.getInfos()); const bindResult = this.bindFile(file); errors.push(...bindResult.getErrors()); warnings.push(...bindResult.getWarnings()); + infos.push(...bindResult.getInfos()); const { ast, @@ -53,6 +56,7 @@ export function interpretProject (this: Compiler): Report { } errors.push(...result.getErrors()); warnings.push(...result.getWarnings()); + infos.push(...result.getInfos()); } const files: Record = {}; @@ -66,5 +70,5 @@ export function interpretProject (this: Compiler): Report { return new Report({ files, - }, errors, warnings); + }, errors, warnings, infos); } diff --git a/packages/dbml-parse/src/compiler/queries/transform/index.ts b/packages/dbml-parse/src/compiler/queries/transform/index.ts index bf41f40a3..96e83fa4e 100644 --- a/packages/dbml-parse/src/compiler/queries/transform/index.ts +++ b/packages/dbml-parse/src/compiler/queries/transform/index.ts @@ -6,4 +6,5 @@ export { type DiagramViewBlock, } from './syncDiagramView'; export { applyTextEdits, type TextEdit } from './applyTextEdits'; +export { addSetting, addSettingEdit } from './addSetting'; export { type TableNameInput } from './utils'; diff --git a/packages/dbml-parse/src/core/global_modules/program/interpret.ts b/packages/dbml-parse/src/core/global_modules/program/interpret.ts index 7f6f0ab04..bf23a5ae0 100644 --- a/packages/dbml-parse/src/core/global_modules/program/interpret.ts +++ b/packages/dbml-parse/src/core/global_modules/program/interpret.ts @@ -1,6 +1,6 @@ import Compiler from '@/compiler/index'; import { CompileError, CompileErrorCode } from '@/core/types/errors'; -import type { CompileWarning } from '@/core/types/errors'; +import type { CompileWarning, CompileInfo } from '@/core/types/errors'; import type { Filepath } from '@/core/types/filepath'; import { UNHANDLED } from '@/core/types/module'; import { ProgramNode } from '@/core/types/nodes'; @@ -29,7 +29,7 @@ import type { ElementRef } from '@/core/types/schemaJson'; import { validateForeignKeys, validatePrimaryKey, validateUnique } from '../records/utils/constraints'; import type { TableInfo } from '../records/utils/constraints/fk'; import { getTokenPosition } from '@/core/utils/interpret'; -import { getMultiplicities } from '../utils'; +import { getMultiplicities } from '@/core/types/relation'; export default class ProgramInterpreter { private compiler: Compiler; @@ -38,6 +38,7 @@ export default class ProgramInterpreter { private filepath: Filepath; private errors: CompileError[] = []; private warnings: CompileWarning[] = []; + private infos: CompileInfo[] = []; private db: Database; constructor (compiler: Compiler, symbol: ProgramSymbol, filepath: Filepath) { @@ -72,7 +73,7 @@ export default class ProgramInterpreter { this.interpretAllMetadata(); this.interpretAllAliases(); this.warnings.push(...this.validateRecords()); - return new Report(this.db, this.errors, this.warnings); + return new Report(this.db, this.errors, this.warnings, this.infos); } private interpretAllSymbols () { @@ -85,6 +86,7 @@ export default class ProgramInterpreter { if (result.hasValue(UNHANDLED)) continue; this.errors.push(...result.getErrors()); this.warnings.push(...result.getWarnings()); + this.infos.push(...result.getInfos()); const value = result.getValue(); if (value) this.pushElement(symbol, value); } @@ -123,6 +125,7 @@ export default class ProgramInterpreter { if (!result.hasValue(UNHANDLED)) { this.errors.push(...result.getErrors()); this.warnings.push(...result.getWarnings()); + this.infos.push(...result.getInfos()); const value = result.getValue(); if (value) this.pushElement(use, value); } @@ -181,6 +184,7 @@ export default class ProgramInterpreter { if (result.hasValue(UNHANDLED)) continue; this.errors.push(...result.getErrors()); this.warnings.push(...result.getWarnings()); + this.infos.push(...result.getInfos()); const value = result.getValue(); if (value === undefined) continue; switch (meta.kind) { 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..f572c0b3e 100644 --- a/packages/dbml-parse/src/core/global_modules/records/interpret.ts +++ b/packages/dbml-parse/src/core/global_modules/records/interpret.ts @@ -233,7 +233,7 @@ function extractValue ( const typeName = (typeInfo?.name ?? '').split('(')[0]; const isEnum = !!typeInfo?.enumSymbol; const increment = colSymbol.increment(compiler); - const notNull = colSymbol.nullable(compiler) === false; + const notNull = colSymbol.isNotNullSet(compiler) === true; const dbdefault = colSymbol.default(compiler); const colName = colSymbol.name ?? ''; const valueType = getRecordValueType(typeName, isEnum); diff --git a/packages/dbml-parse/src/core/global_modules/records/utils/constraints/fk.ts b/packages/dbml-parse/src/core/global_modules/records/utils/constraints/fk.ts index b83179c33..7eea6487c 100644 --- a/packages/dbml-parse/src/core/global_modules/records/utils/constraints/fk.ts +++ b/packages/dbml-parse/src/core/global_modules/records/utils/constraints/fk.ts @@ -3,6 +3,8 @@ import type Compiler from '@/compiler/index'; import type { CompileWarning } from '@/core/types/errors'; import type { Filepath } from '@/core/types/filepath'; import type { SyntaxNode } from '@/core/types/nodes'; +import { parseCardinality } from '@/core/types/relation'; +import type { RelationCardinality } from '@/core/types/relation'; import type { Ref, RefEndpoint, TableRecord } from '@/core/types/schemaJson'; import type { TableSymbol } from '@/core/types/symbol'; import type { InternedNodeSymbol } from '@/core/types/symbol/symbols'; @@ -63,63 +65,16 @@ export function validateForeignKeys ( return ref.endpoints.some((ep) => tablesWithRecords.has(makeTableKey(ep.schemaName, ep.tableName))); }); - return flatMap(relevantRefs, (ref) => validateRef(compiler, ref, tableInfoLookup, filepath)); + return flatMap(relevantRefs, (ref) => validateForeignKey(compiler, ref, tableInfoLookup, filepath)); } -// Validate that source's FK values exist in target's values -function validateFkSourceToTarget ( +// Validate 1 foreign key constraint only +function validateForeignKey ( compiler: Compiler, - sourceTable: TableInfo, - targetTable: TableInfo, - sourceEndpoint: RefEndpoint, - targetEndpoint: RefEndpoint, + ref: Ref, // The constraint to validate + tableInfoLookup: Map, // Fast info lookup filepath: Filepath, ): CompileWarning[] { - if (!sourceTable.record || isEmpty(sourceTable.record.values)) return []; - - const sourceRows = toKeyedRows(sourceTable.record); - const targetRows = targetTable.record ? toKeyedRows(targetTable.record) : []; - - // Build set of valid target values for FK reference check - const validFkValues = new Set( - targetRows.map((row) => extractKeyValueWithDefault(row, targetEndpoint.fieldNames)), - ); - - // Filter rows with NULL values (optional relationships) - const rowsWithValues = sourceRows - .filter((row) => !hasNullWithoutDefaultInKey(row, sourceEndpoint.fieldNames)); - - // Find rows with FK values that don't exist in target - const invalidRows = rowsWithValues.filter((row) => { - const fkValue = extractKeyValueWithDefault(row, sourceEndpoint.fieldNames); - return !validFkValues.has(fkValue); - }); - - const sourceName = sourceTable.tableSymbol.interpretedName(compiler, filepath); - const targetName = targetTable.tableSymbol.interpretedName(compiler, filepath); - - // Transform invalid rows to warnings - return flatMap(invalidRows, (row) => { - const sourceColumnRef = formatFullColumnNames( - sourceName.schema, - sourceName.name, - sourceEndpoint.fieldNames, - ); - const targetColumnRef = formatFullColumnNames( - targetName.schema, - targetName.name, - targetEndpoint.fieldNames, - ); - const valueStr = formatValues(row, sourceEndpoint.fieldNames); - const message = `FK violation: ${sourceColumnRef} = ${valueStr} does not exist in ${targetColumnRef}`; - - return sourceEndpoint.fieldNames.map((col) => - createConstraintWarning(compiler, row[col], message), - ); - }); -} - -function validateRef (compiler: Compiler, ref: Ref, tableInfoLookup: Map, filepath: Filepath): CompileWarning[] { if (!ref.endpoints) return []; const [ @@ -131,37 +86,71 @@ function validateRef (compiler: Compiler, ref: Ref, tableInfoLookup: Map left allows NULL +// - right min >= 1 -> left must not be NULL +// - right max = 1 -> left must map to exactly 1 right row (FK existence) +// - right max = * -> no FK constraint +function validateEndpoint ( compiler: Compiler, - table1: TableInfo, - table2: TableInfo, - endpoint1: RefEndpoint, - endpoint2: RefEndpoint, + leftTable: TableInfo, + leftEndpoint: RefEndpoint, + rightTable: TableInfo, + rightEndpoint: RefEndpoint, + rightCard: RelationCardinality, // This will constrains the left table's records filepath: Filepath, ): CompileWarning[] { - const rel1 = endpoint1.relation; - const rel2 = endpoint2.relation; - - // Bidirectional relationships: both 1-1 and many-to-many - const isBidirectional = (rel1 === '1' && rel2 === '1') || (rel1 === '*' && rel2 === '*'); - if (isBidirectional) { - return [ - ...validateFkSourceToTarget(compiler, table1, table2, endpoint1, endpoint2, filepath), - ...validateFkSourceToTarget(compiler, table2, table1, endpoint2, endpoint1, filepath), - ]; - } + if (!leftTable.record || isEmpty(leftTable.record.values)) return []; - // Many-to-one: validate FK from "many" side to "one" side - if (rel1 === '*' && rel2 === '1') { - return validateFkSourceToTarget(compiler, table1, table2, endpoint1, endpoint2, filepath); - } + const { min: rightMin } = parseCardinality(rightCard); - if (rel1 === '1' && rel2 === '*') { - return validateFkSourceToTarget(compiler, table2, table1, endpoint2, endpoint1, filepath); - } + // right min = 0 -> left FK values may be NULL (optional relationship) + // right min >= 1 -> left FK values must not be NULL + const allowNull = rightMin === 0; + + const leftRows = toKeyedRows(leftTable.record); + const rightRows = rightTable.record ? toKeyedRows(rightTable.record) : []; + + const validFkValues = new Set( + rightRows.map((row) => extractKeyValueWithDefault(row, rightEndpoint.fieldNames)), + ); - return []; + const leftName = leftTable.tableSymbol.interpretedName(compiler, filepath); + const rightName = rightTable.tableSymbol.interpretedName(compiler, filepath); + + return flatMap(leftRows, (row) => { + const isNull = hasNullWithoutDefaultInKey(row, leftEndpoint.fieldNames); + + if (isNull) { + if (allowNull) return []; + const leftColumnRef = formatFullColumnNames(leftName.schema, leftName.name, leftEndpoint.fieldNames); + const valueStr = formatValues(row, leftEndpoint.fieldNames); + const message = `FK violation: ${leftColumnRef} = ${valueStr} must not be null`; + return leftEndpoint.fieldNames.map((col) => + createConstraintWarning(compiler, row[col], message), + ); + } + + // right max = 1 -> non-null left value must map to exactly 1 right row + // right max = * -> non-null left value must exist in right + // Both cases: left value must exist in right values + const fkValue = extractKeyValueWithDefault(row, leftEndpoint.fieldNames); + if (validFkValues.has(fkValue)) return []; + + const leftColumnRef = formatFullColumnNames(leftName.schema, leftName.name, leftEndpoint.fieldNames); + const rightColumnRef = formatFullColumnNames(rightName.schema, rightName.name, rightEndpoint.fieldNames); + const valueStr = formatValues(row, leftEndpoint.fieldNames); + const message = `FK violation: ${leftColumnRef} = ${valueStr} does not exist in ${rightColumnRef}`; + return leftEndpoint.fieldNames.map((col) => + createConstraintWarning(compiler, row[col], message), + ); + }); } diff --git a/packages/dbml-parse/src/core/global_modules/records/utils/constraints/helper.ts b/packages/dbml-parse/src/core/global_modules/records/utils/constraints/helper.ts index f5bc0f005..7bd2615ae 100644 --- a/packages/dbml-parse/src/core/global_modules/records/utils/constraints/helper.ts +++ b/packages/dbml-parse/src/core/global_modules/records/utils/constraints/helper.ts @@ -25,7 +25,7 @@ export function columnInfoFromSymbol (col: ColumnSymbol, compiler: Compiler): Co pk: col.pk(compiler), unique: col.unique(compiler), increment: col.increment(compiler), - notNull: col.nullable(compiler) === false, + notNull: col.isNotNullSet(compiler) === true, dbdefault: def, typeName: type?.name ?? '', }; 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 60a0764a4..3cad0cc71 100644 --- a/packages/dbml-parse/src/core/global_modules/ref/interpret.ts +++ b/packages/dbml-parse/src/core/global_modules/ref/interpret.ts @@ -4,16 +4,20 @@ import { } from '@/core/utils/expression'; import { aggregateSettingList } from '@/core/utils/validate'; import { extractStringFromIdentifierStream } from '@/core/utils/expression'; -import { CompileError, CompileErrorCode } from '@/core/types/errors'; +import { CompileError, CompileErrorCode, CompileInfo } from '@/core/types/errors'; +import type { SyntaxToken } from '@/core/types/tokens'; import { ElementDeclarationNode, FunctionApplicationNode, + InfixExpressionNode, IdentifierStreamNode, - type ListExpressionNode, + ListExpressionNode, AttributeNode, + PrefixExpressionNode, } from '@/core/types/nodes'; import type { Ref } from '@/core/types/schemaJson'; import { + ColumnSymbol, RefMetadata, type Filepath, } from '@/core/types'; @@ -23,7 +27,13 @@ import { getTokenPosition, } from '@/core/utils/interpret'; import Report from '@/core/types/report'; -import { getMultiplicities } from '../utils'; +import { addSettingEdit } from '@/compiler/queries/transform/addSetting'; +import { + getMultiplicities, getRelationshipOp, parseCardinality, + makeCardinalityOptional, makeCardinalityRequired, makeCardinalityMany, +} from '@/core/types/relation'; +import type { RelationCardinality } from '@/core/types/relation'; +import type { QuickFix } from '@/core/types/errors'; import { zip } from 'lodash-es'; export class RefInterpreter { @@ -47,7 +57,8 @@ export class RefInterpreter { ...this.interpretName(), ...this.interpretBody(), ]; - return Report.create(this.ref as Ref, errors); + const { infos } = this.validateRefConstraints(); + return Report.create(this.ref as Ref, errors, undefined, infos); } private interpretName (): CompileError[] { @@ -153,4 +164,124 @@ export class RefInterpreter { return []; } + + private validateRefConstraints (): { infos: CompileInfo[] } { + if (!this.metadata.cardinalities(this.compiler)) return { infos: [] }; + return { + infos: [ + ...this.validateCardinality('right'), + ...this.validateCardinality('left'), + ], + }; + } + + private getOpToken (): SyntaxToken | undefined { + if (this.declarationNode instanceof ElementDeclarationNode) { + const field = getBody(this.declarationNode)[0]; + if (!(field instanceof FunctionApplicationNode)) return undefined; + const infix = field.callee; + if (!(infix instanceof InfixExpressionNode)) return undefined; + return infix.op; + } + if (this.declarationNode instanceof AttributeNode) { + const prefix = this.declarationNode.value; + if (!(prefix instanceof PrefixExpressionNode)) return undefined; + return prefix.op; + } + return undefined; + } + + // A cardinality constrains: + // min >= 1 -> otherColumns must be NOT NULL + // min = 0 -> otherColumns may be nullable; info if NOT NULL + // max = 1 -> ownColumns must be unique/pk + private validateCardinality (side: 'left' | 'right'): CompileInfo[] { + const cardinalities = this.metadata.cardinalities(this.compiler)!; + const thisRel = side === 'left' ? cardinalities[0] : cardinalities[1]; + const otherRel = side === 'left' ? cardinalities[1] : cardinalities[0]; + const card = parseCardinality(thisRel); + + const otherColumns = side === 'left' ? this.metadata.rightColumns(this.compiler) : this.metadata.leftColumns(this.compiler); + const ownColumns = side === 'left' ? this.metadata.leftColumns(this.compiler) : this.metadata.rightColumns(this.compiler); + const otherNode = side === 'left' ? this.metadata.rightToken() : this.metadata.leftToken(); + const ownNode = side === 'left' ? this.metadata.leftToken() : this.metadata.rightToken(); + const opToken = this.getOpToken(); + + const infos: CompileInfo[] = []; + + if (card.min >= 1) { + for (const col of otherColumns) { + if (col.nullable(this.compiler)) { + const msg = `Column '${col.name}' is nullable but operator implies mandatory`; + const fixes = [ + opToken && suggestChangeOp(opToken, makeCardinalityOptional(thisRel), otherRel, side, `make '${col.name}' optional`), + suggestAddSetting(col, 'not null'), + ].filter((f): f is QuickFix => !!f); + infos.push(new CompileInfo(CompileErrorCode.INVALID_REF_RELATIONSHIP, msg, otherNode, fixes)); + if (col.declaration) infos.push(new CompileInfo(CompileErrorCode.INVALID_REF_RELATIONSHIP, msg, col.declaration, fixes)); + } + } + } + + if (card.min === 0) { + for (const col of otherColumns) { + if (col.nullable(this.compiler) === false) { + const msg = `Column '${col.name}' is NOT NULL but operator marks it optional`; + const fixes = [ + opToken && suggestChangeOp(opToken, makeCardinalityRequired(thisRel), otherRel, side, `make '${col.name}' required`), + ].filter((f): f is QuickFix => !!f); + infos.push(new CompileInfo(CompileErrorCode.INVALID_REF_RELATIONSHIP, msg, otherNode, fixes)); + if (col.declaration) infos.push(new CompileInfo(CompileErrorCode.INVALID_REF_RELATIONSHIP, msg, col.declaration, fixes)); + } + } + } + + if (card.max === 1 && ownColumns.length === 1) { + const col = ownColumns[0]; + if (!col.unique(this.compiler) && !col.pk(this.compiler)) { + const msg = `Column '${col.name}' should be unique or primary key for a one-side relationship`; + const fixes = [ + opToken && suggestChangeOp(opToken, makeCardinalityMany(thisRel), otherRel, side, `make '${col.name}' many`), + suggestAddSetting(col, 'unique'), + ].filter((f): f is QuickFix => !!f); + infos.push(new CompileInfo(CompileErrorCode.INVALID_REF_RELATIONSHIP, msg, ownNode, fixes)); + if (col.declaration) infos.push(new CompileInfo(CompileErrorCode.INVALID_REF_RELATIONSHIP, msg, col.declaration, fixes)); + } + } + + return infos; + } +} + +function suggestChangeOp ( + opToken: SyntaxToken, + newThisRel: RelationCardinality, + otherRel: RelationCardinality, + side: 'left' | 'right', + description: string, +): QuickFix { + const newLeft = side === 'left' ? newThisRel : otherRel; + const newRight = side === 'right' ? newThisRel : otherRel; + const newOp = getRelationshipOp(newLeft, newRight); + return { + title: `Change operator to '${newOp}' (${description})`, + filepath: opToken.filepath, + edits: [ + { start: opToken.start, end: opToken.end, newText: newOp }, + ], + }; +} + +function suggestAddSetting (col: ColumnSymbol, setting: string): QuickFix | undefined { + if (!col.declaration) return undefined; + const edit = addSettingEdit(col.declaration, setting); + if (!edit) return undefined; + + return { + title: `Make '${col.name}' ${setting}`, + filepath: col.declaration.filepath, + edits: [ + edit, + ], + }; } diff --git a/packages/dbml-parse/src/core/global_modules/table/interpret.ts b/packages/dbml-parse/src/core/global_modules/table/interpret.ts index c3b90391e..457ddd83b 100644 --- a/packages/dbml-parse/src/core/global_modules/table/interpret.ts +++ b/packages/dbml-parse/src/core/global_modules/table/interpret.ts @@ -1,4 +1,4 @@ -import { last, partition } from 'lodash-es'; +import { partition } from 'lodash-es'; import Compiler from '@/compiler'; import { CompileError, CompileErrorCode } from '@/core/types/errors'; import { ElementKind, SettingName } from '@/core/types/keywords'; @@ -272,8 +272,7 @@ export class TableInterpreter { column.pk = columnSymbol?.pk(this.compiler) || false; column.unique = columnSymbol?.unique(this.compiler) || false; column.increment = columnSymbol?.increment(this.compiler) || undefined; - const nullable = columnSymbol?.nullable(this.compiler); - column.not_null = nullable === undefined ? undefined : !nullable; + column.not_null = columnSymbol?.isNotNullSet(this.compiler); column.dbdefault = columnSymbol?.default(this.compiler); column.note = columnSymbol?.note(this.compiler); diff --git a/packages/dbml-parse/src/core/global_modules/tablePartial/interpret.ts b/packages/dbml-parse/src/core/global_modules/tablePartial/interpret.ts index 11a845e14..a2d631f12 100644 --- a/packages/dbml-parse/src/core/global_modules/tablePartial/interpret.ts +++ b/packages/dbml-parse/src/core/global_modules/tablePartial/interpret.ts @@ -185,8 +185,7 @@ export class TablePartialInterpreter { column.pk = columnSymbol?.pk(this.compiler) || undefined; column.unique = columnSymbol?.unique(this.compiler) || undefined; column.increment = columnSymbol?.increment(this.compiler) || undefined; - const nullable = columnSymbol?.nullable(this.compiler); - column.not_null = nullable === undefined ? undefined : !nullable; + column.not_null = columnSymbol?.isNotNullSet(this.compiler); column.dbdefault = columnSymbol?.default(this.compiler); column.note = columnSymbol?.note(this.compiler); diff --git a/packages/dbml-parse/src/core/global_modules/utils.ts b/packages/dbml-parse/src/core/global_modules/utils.ts index 94d19d100..a316f2343 100644 --- a/packages/dbml-parse/src/core/global_modules/utils.ts +++ b/packages/dbml-parse/src/core/global_modules/utils.ts @@ -1,6 +1,5 @@ import type Compiler from '@/compiler'; import { getMemberChain } from '@/core/parser/utils'; -import type { RelationCardinality } from '@/core/types'; import { UNHANDLED } from '@/core/types/module'; import { InfixExpressionNode, PostfixExpressionNode, PrefixExpressionNode, PrimaryExpressionNode, SyntaxNode, TupleExpressionNode, VariableNode, @@ -42,6 +41,11 @@ export function getNodeMemberSymbols (compiler: Compiler, node: SyntaxNode): Rep ...(symbol.hasValue(UNHANDLED) ? [] : symbol.getWarnings()), ...(nestedSymbols.hasValue(UNHANDLED) ? [] : nestedSymbols.getWarnings()), ], + [ + ...report.getInfos(), + ...(symbol.hasValue(UNHANDLED) ? [] : symbol.getInfos()), + ...(nestedSymbols.hasValue(UNHANDLED) ? [] : nestedSymbols.getInfos()), + ], ); }, new Report([]), @@ -110,32 +114,3 @@ export function nodeRefereeOfLeftExpression (compiler: Compiler, node: SyntaxNod } return compiler.nodeReferee(leftExpr).getFiltered(UNHANDLED) ?? undefined; } - -export function getMultiplicities ( - op: string, -): [RelationCardinality, RelationCardinality] | undefined { - switch (op) { - case '<': - return [ - '1', - '*', - ]; - case '<>': - return [ - '*', - '*', - ]; - case '>': - return [ - '*', - '1', - ]; - case '-': - return [ - '1', - '1', - ]; - default: - return undefined; - } -} diff --git a/packages/dbml-parse/src/core/lexer/lexer.ts b/packages/dbml-parse/src/core/lexer/lexer.ts index a12c70990..49d79f964 100644 --- a/packages/dbml-parse/src/core/lexer/lexer.ts +++ b/packages/dbml-parse/src/core/lexer/lexer.ts @@ -386,19 +386,44 @@ export default class Lexer { operator (c: string) { switch (c) { case '<': - if ([ - '>', - '=', - ].includes(this.peek()!)) this.advance(); // <, >, <= + if (this.peek() === '=') { + this.advance(); // <= + } else if (this.peek() === '>') { + this.advance(); // <> + if (this.peek() === '?') this.advance(); // <>? + } else if (this.peek() === '?') { + this.advance(); // ': - if (this.peek() === '=') this.advance(); // >, >= + if (this.peek() === '=') this.advance(); // >= + else if (this.peek() === '?') this.advance(); // >? + break; + case '-': + if (this.peek() === '?') this.advance(); // -? + break; + case '?': + if (this.peek() === '-') { + this.advance(); // ?- + if (this.peek() === '?') this.advance(); // ?-? + } else if (this.peek() === '<') { + this.advance(); // ?< + if (this.peek() === '>') { + this.advance(); // ?<> + if (this.peek() === '?') this.advance(); // ?<>? + } else if (this.peek() === '?') { + this.advance(); // ?') { + this.advance(); // ?> + if (this.peek() === '?') this.advance(); // ?>? + } break; case '=': - if (this.peek() === '=') this.advance(); // =, == + if (this.peek() === '=') this.advance(); // == break; case '!': - if (this.peek() === '=') this.advance(); // !, != + if (this.peek() === '=') this.advance(); // != break; default: break; diff --git a/packages/dbml-parse/src/core/parser/parser.ts b/packages/dbml-parse/src/core/parser/parser.ts index 20acd45a1..54dff968c 100644 --- a/packages/dbml-parse/src/core/parser/parser.ts +++ b/packages/dbml-parse/src/core/parser/parser.ts @@ -1545,6 +1545,54 @@ const infixBindingPowerMap: { left: 7, right: 8, }, + '-?': { + left: 9, + right: 10, + }, + '?-': { + left: 9, + right: 10, + }, + '?-?': { + left: 9, + right: 10, + }, + '?>': { + left: 7, + right: 8, + }, + '>?': { + left: 7, + right: 8, + }, + '?<': { + left: 7, + right: 8, + }, + '?': { + left: 7, + right: 8, + }, + '?': { + left: 7, + right: 8, + }, + '<>?': { + left: 7, + right: 8, + }, + '?<>?': { + left: 7, + right: 8, + }, '=': { left: 2, right: 3, @@ -1600,6 +1648,54 @@ const prefixBindingPowerMap: { left: null, right: 15, }, + '-?': { + left: null, + right: 15, + }, + '?-': { + left: null, + right: 15, + }, + '?-?': { + left: null, + right: 15, + }, + '?>': { + left: null, + right: 15, + }, + '>?': { + left: null, + right: 15, + }, + '?<': { + left: null, + right: 15, + }, + '?': { + left: null, + right: 15, + }, + '?': { + left: null, + right: 15, + }, + '<>?': { + left: null, + right: 15, + }, + '?<>?': { + left: null, + right: 15, + }, '!': { left: null, right: 15, diff --git a/packages/dbml-parse/src/core/types/errors.ts b/packages/dbml-parse/src/core/types/errors.ts index 3e3f24872..0eddd1bbb 100644 --- a/packages/dbml-parse/src/core/types/errors.ts +++ b/packages/dbml-parse/src/core/types/errors.ts @@ -200,3 +200,39 @@ export class CompileWarning extends Error { return this.nodeOrToken.filepath; } } + +export interface QuickFix { + title: string; + filepath: Filepath; + edits: import('@/compiler/queries/transform/applyTextEdits').TextEdit[]; +} + +export class CompileInfo extends Error { + code: Readonly; + + diagnostic: Readonly; + + nodeOrToken: Readonly; + + start: Readonly; + + end: Readonly; + + quickFixes?: QuickFix[]; + + constructor (code: number, message: string, nodeOrToken: SyntaxNode | SyntaxToken, quickFixes?: QuickFix[]) { + super(message); + this.code = code; + this.diagnostic = message; + this.nodeOrToken = nodeOrToken; + this.start = nodeOrToken.start; + this.end = nodeOrToken.end; + this.quickFixes = quickFixes; + this.name = this.constructor.name; + Object.setPrototypeOf(this, CompileInfo.prototype); + } + + get filepath (): Filepath { + return this.nodeOrToken.filepath; + } +} diff --git a/packages/dbml-parse/src/core/types/relation.ts b/packages/dbml-parse/src/core/types/relation.ts new file mode 100644 index 000000000..5846b0e52 --- /dev/null +++ b/packages/dbml-parse/src/core/types/relation.ts @@ -0,0 +1,217 @@ +type BaseRelationshipOp = '>' | '<' | '-' | '<>'; +export type RelationshipOp = `${'?' | ''}${BaseRelationshipOp}${'?' | ''}`; + +// A cardinality is either: +// - a single number: exactly N (for backwards compatibility) +// - '*': shorthand for 1..* (for backwards compatibility) +// - 'min..max': a range (e.g. '0..1', '1..*', '2..5') +export type RelationCardinality = `${number}` | '*' | `${number}..${number | '*'}`; + +// Collapse 'N..N' to just 'N' when min equals max +// This is for backwards compatibility: +// - '1': 1..1 +// - '*': 1..many +export function normalizeCardinality (c: RelationCardinality): RelationCardinality { + if (c === '*') return c; + const { min, max } = parseCardinality(c); + if (typeof max === 'number' && min === max) return `${min}` as RelationCardinality; + return c; +} + +// Parse any RelationCardinality into its numeric min/max +export function parseCardinality (c: RelationCardinality): { min: number; max: number | '*' } { + if (c === '*') return { min: 1, max: '*' }; + const [ + min, + max, + ] = c.split('..'); + const minNum = Number(min); + if (max === undefined) return { min: minNum, max: minNum }; + return { + min: minNum, + max: max === '*' ? '*' : Number(max), + }; +} + +export const RELATIONSHIP_OPS: ReadonlySet = new Set([ + '-', + '<>', + '>', + '<', + '-?', + '?-', + '?-?', + '?>', + '>?', + '?>?', + '?<', + '', + '<>?', + '?<>?', +]); + +export const CARDINALITY_ONE: RelationCardinality = '1'; +export const CARDINALITY_MAYBE: RelationCardinality = '0..1'; +export const CARDINALITY_SOME: RelationCardinality = '*'; // Equivalent to '1..*' +export const CARDINALITY_MANY: RelationCardinality = '0..*'; + +export function getMultiplicities ( + op: string, +): [RelationCardinality, RelationCardinality] | undefined { + switch (op) { + case '-': return [ + CARDINALITY_ONE, + CARDINALITY_ONE, + ]; + case '<>': return [ + CARDINALITY_SOME, + CARDINALITY_SOME, + ]; + case '>': return [ + CARDINALITY_SOME, + CARDINALITY_ONE, + ]; + case '<': return [ + CARDINALITY_ONE, + CARDINALITY_SOME, + ]; + // optional-one variants + case '-?': return [ + CARDINALITY_ONE, + CARDINALITY_MAYBE, + ]; + case '?-': return [ + CARDINALITY_MAYBE, + CARDINALITY_ONE, + ]; + case '?-?': return [ + CARDINALITY_MAYBE, + CARDINALITY_MAYBE, + ]; + // directional with optional side + case '?>': return [ + CARDINALITY_MANY, + CARDINALITY_ONE, + ]; + case '>?': return [ + CARDINALITY_SOME, + CARDINALITY_MAYBE, + ]; + case '?>?': return [ + CARDINALITY_MANY, + CARDINALITY_MAYBE, + ]; + case '?<': return [ + CARDINALITY_MAYBE, + CARDINALITY_SOME, + ]; + case '': return [ + CARDINALITY_MANY, + CARDINALITY_SOME, + ]; + case '<>?': return [ + CARDINALITY_SOME, + CARDINALITY_MANY, + ]; + case '?<>?': return [ + CARDINALITY_MANY, + CARDINALITY_MANY, + ]; + default: + return undefined; + } +} + +// Reverse of getMultiplicities: cardinality pair -> operator. +export function getRelationshipOp ( + left: RelationCardinality, + right: RelationCardinality, +): RelationshipOp { + const { min: leftMin, max: leftMax } = parseCardinality(left); + const { min: rightMin, max: rightMax } = parseCardinality(right); + + // Left is [1..1] (CARDINALITY_ONE) + if (leftMin === 1 && leftMax === 1) { + // Right is [1..1] (CARDINALITY_ONE) + if (rightMin === 1 && rightMax === 1) return '-'; + // Right is [0..1] (CARDINALITY_MAYBE) + if (rightMin === 0 && rightMax === 1) return '-?'; + // Right is [0..*] (CARDINALITY_MANY) + if (rightMin === 0 && rightMax === '*') return '= 1 && rightMax === '*') return '<'; + } + + // Left is [0..1] (CARDINALITY_MAYBE) + if (leftMin === 0 && leftMax === 1) { + // Right is [1..1] (CARDINALITY_ONE) + if (rightMin === 1 && rightMax === 1) return '?-'; + // Right is [0..1] (CARDINALITY_MAYBE) + if (rightMin === 0 && rightMax === 1) return '?-?'; + // Right is [0..*] (CARDINALITY_MANY) + if (rightMin === 0 && rightMax === '*') return '?= 1 && rightMax === '*') return '?<'; + } + + // Left is [0..*] + if (leftMin === 0 && leftMax === '*') { + // Right is [1..1] (CARDINALITY_ONE) + if (rightMin === 1 && rightMax === 1) return '?>'; + // Right is [0..1] (CARDINALITY_MAYBE) + if (rightMin === 0 && rightMax === 1) return '?>?'; + // Right is [0..*] + if (rightMin === 0 && rightMax === '*') return '?<>?'; + // Right is [1..*] (CARDINALITY_SOME) + if (rightMin >= 1 && rightMax === '*') return '?<>'; + } + + // Left is [1..*] (CARDINALITY_SOME / CARDINALITY_MANY) + if (leftMin >= 1 && leftMax === '*') { + // Right is [1..1] (CARDINALITY_ONE) + if (rightMin === 1 && rightMax === 1) return '>'; + // Right is [0..1] (CARDINALITY_MAYBE) + if (rightMin === 0 && rightMax === 1) return '>?'; + // Right is [0..*] + if (rightMin === 0 && rightMax === '*') return '<>?'; + // Right is [1..*] (CARDINALITY_SOME) + if (rightMin >= 1 && rightMax === '*') return '<>'; + } + + return '<>'; +} + +// Cardinality transforms: adjust min or max while preserving the other. +// Used by code actions to suggest operator changes. + +// Set min to 0 (allow null): 1 -> 0..1, * -> 0..* +export function makeCardinalityOptional (rel: RelationCardinality): RelationCardinality { + const { min, max } = parseCardinality(rel); + if (min === 0) return rel; + return max === '*' ? CARDINALITY_MANY : CARDINALITY_MAYBE; +} + +// Set min to 1 (require not null): 0..1 -> 1, 0..* -> * +export function makeCardinalityRequired (rel: RelationCardinality): RelationCardinality { + const { min, max } = parseCardinality(rel); + if (min >= 1) return rel; + return max === '*' ? CARDINALITY_SOME : CARDINALITY_ONE; +} + +// Set max to * (allow many): 1 -> *, 0..1 -> 0..* +export function makeCardinalityMany (rel: RelationCardinality): RelationCardinality { + const { min, max } = parseCardinality(rel); + if (max === '*') return rel; + return min === 0 ? CARDINALITY_MANY : CARDINALITY_SOME; +} diff --git a/packages/dbml-parse/src/core/types/report.ts b/packages/dbml-parse/src/core/types/report.ts index c9ed20ad7..7575eedc8 100644 --- a/packages/dbml-parse/src/core/types/report.ts +++ b/packages/dbml-parse/src/core/types/report.ts @@ -1,6 +1,6 @@ -import { CompileError, CompileWarning } from './errors'; +import { CompileError, CompileWarning, CompileInfo } from './errors'; -// Used to hold the result of a computation and any errors/warnings along the way +// Used to hold the result of a computation and any errors/warnings/infos along the way export default class Report { private value: T; @@ -8,20 +8,21 @@ export default class Report { private warnings?: CompileWarning[]; - static create (value: T, errors?: CompileError[], warnings?: CompileWarning[]) { - return new Report(value, errors, warnings); + private infos?: CompileInfo[]; + + static create (value: T, errors?: CompileError[], warnings?: CompileWarning[], infos?: CompileInfo[]) { + return new Report(value, errors, warnings, infos); } - constructor (value: T, errors?: CompileError[], warnings?: CompileWarning[]) { + constructor (value: T, errors?: CompileError[], warnings?: CompileWarning[], infos?: CompileInfo[]) { this.value = value; - this.errors = errors === undefined ? [] : errors; - if (warnings?.length) { - this.warnings = warnings; - } + this.errors = errors ?? []; + this.warnings = warnings; + this.infos = infos; } filter (filteredValue: S): Report> { - if (this.value as any === filteredValue) return new Report(undefined, this.errors, this.warnings); + if (this.value as any === filteredValue) return new Report(undefined, this.errors, this.warnings, this.infos); return this as Report>; } @@ -44,7 +45,11 @@ export default class Report { } getWarnings (): CompileWarning[] { - return this.warnings || []; + return this.warnings ?? []; + } + + getInfos (): CompileInfo[] { + return this.infos ?? []; } // Chain the reported value @@ -52,7 +57,7 @@ export default class Report { // 2. If `fn` produces further warnings or errors, accumulate // If the reported value is filteredValue, return undefined chainFiltered(fn: (_: Exclude) => Report, filteredValue: S): Report { - if (this.value as any === filteredValue) return new Report(undefined, this.errors, this.warnings); + if (this.value as any === filteredValue) return new Report(undefined, this.errors, this.warnings, this.infos); const res = fn(this.value as Exclude); const errors = [ ...this.errors, @@ -62,8 +67,12 @@ export default class Report { ...this.getWarnings(), ...res.getWarnings(), ]; + const infos = [ + ...this.getInfos(), + ...res.getInfos(), + ]; - return new Report(res.value, errors, warnings); + return new Report(res.value, errors, warnings, infos); } chain(fn: (_: T) => Report): Report { @@ -76,8 +85,12 @@ export default class Report { ...this.getWarnings(), ...res.getWarnings(), ]; + const infos = [ + ...this.getInfos(), + ...res.getInfos(), + ]; - return new Report(res.value, errors, warnings); + return new Report(res.value, errors, warnings, infos); } // Map the reported value @@ -85,11 +98,11 @@ export default class Report { // 2. `fn` cannot produce further warnings or errors // If the reported value is filteredValue, return undefined mapFiltered(fn: (_: Exclude) => U, filteredValue: S): Report { - if (this.value as any === filteredValue) return new Report(undefined, this.errors, this.warnings); - return new Report(fn(this.value as Exclude), this.errors, this.warnings); + if (this.value as any === filteredValue) return new Report(undefined, this.errors, this.warnings, this.infos); + return new Report(fn(this.value as Exclude), this.errors, this.warnings, this.infos); } map(fn: (_: T) => U): Report { - return new Report(fn(this.value), this.errors, this.warnings); + return new Report(fn(this.value), this.errors, this.warnings, this.infos); } } diff --git a/packages/dbml-parse/src/core/types/schemaJson.ts b/packages/dbml-parse/src/core/types/schemaJson.ts index 4713e6479..dceb00b06 100644 --- a/packages/dbml-parse/src/core/types/schemaJson.ts +++ b/packages/dbml-parse/src/core/types/schemaJson.ts @@ -1,6 +1,7 @@ import { NONE_COLOR } from '@/constants'; import type { Filepath } from './filepath'; import type { Position } from './position'; +import type { RelationshipOp, RelationCardinality } from './relation'; export type Color = `#${string}` | typeof NONE_COLOR; @@ -164,7 +165,7 @@ export interface InlineRef { schemaName: string | null; tableName: string; fieldNames: string[]; - relation: '>' | '<' | '-' | '<>'; + relation: RelationshipOp; token: TokenPosition; } @@ -189,8 +190,6 @@ export interface RefEndpoint { token: TokenPosition; } -export type RelationCardinality = '1' | '*'; - export interface Enum { name: string; schemaName: string | null; diff --git a/packages/dbml-parse/src/core/types/symbol/metadata.ts b/packages/dbml-parse/src/core/types/symbol/metadata.ts index 5b209af48..d004ce8b2 100644 --- a/packages/dbml-parse/src/core/types/symbol/metadata.ts +++ b/packages/dbml-parse/src/core/types/symbol/metadata.ts @@ -16,6 +16,8 @@ import type { TableSymbol, } from '../symbol'; import type { Internable } from '../internable'; +import type { RelationshipOp, RelationCardinality } from '../relation'; +import { getMultiplicities, parseCardinality } from '../relation'; import { UNHANDLED } from '../module'; import { ElementKind, SettingName } from '../keywords'; import { @@ -145,22 +147,37 @@ export class RefMetadata extends NodeMetadata { return undefined; } - op (_compiler: Compiler): '>' | '<' | '-' | '<>' | undefined { + op (_compiler: Compiler): RelationshipOp | undefined { if (this.declaration instanceof ElementDeclarationNode) { const field = getBody(this.declaration)[0]; if (!(field instanceof FunctionApplicationNode)) return undefined; const infix = field.callee; if (!(infix instanceof InfixExpressionNode)) return undefined; - return infix.op?.value as '>' | '<' | '-' | '<>' | undefined; + return infix.op?.value as RelationshipOp | undefined; } if (this.declaration instanceof AttributeNode) { const prefix = this.declaration.value; if (!(prefix instanceof PrefixExpressionNode)) return undefined; - return prefix.op?.value as '>' | '<' | '-' | '<>' | undefined; + return prefix.op?.value as RelationshipOp | undefined; } return undefined; } + cardinalities (compiler: Compiler): [RelationCardinality, RelationCardinality] | undefined { + const op = this.op(compiler); + return op ? getMultiplicities(op) : undefined; + } + + leftCardinality (compiler: Compiler): { min: number; max: number | '*' } | undefined { + const c = this.cardinalities(compiler); + return c ? parseCardinality(c[0]) : undefined; + } + + rightCardinality (compiler: Compiler): { min: number; max: number | '*' } | undefined { + const c = this.cardinalities(compiler); + return c ? parseCardinality(c[1]) : undefined; + } + active (compiler: Compiler): boolean { if (!(this.declaration instanceof ElementDeclarationNode)) return true; const field = getBody(this.declaration)[0]; @@ -271,11 +288,26 @@ export class PartialRefMetadata extends NodeMetadata { return extractTableFromEndpoint(compiler, prefix.expression) ?? this.container(compiler); } - op (_compiler: Compiler): '>' | '<' | '-' | '<>' | undefined { + op (_compiler: Compiler): RelationshipOp | undefined { if (!(this.declaration instanceof AttributeNode)) return undefined; const prefix = this.declaration.value; if (!(prefix instanceof PrefixExpressionNode)) return undefined; - return prefix.op?.value as '>' | '<' | '-' | '<>' | undefined; + return prefix.op?.value as RelationshipOp | undefined; + } + + cardinalities (compiler: Compiler): [RelationCardinality, RelationCardinality] | undefined { + const op = this.op(compiler); + return op ? getMultiplicities(op) : undefined; + } + + leftCardinality (compiler: Compiler): { min: number; max: number | '*' } | undefined { + const c = this.cardinalities(compiler); + return c ? parseCardinality(c[0]) : undefined; + } + + rightCardinality (compiler: Compiler): { min: number; max: number | '*' } | undefined { + const c = this.cardinalities(compiler); + return c ? parseCardinality(c[1]) : undefined; } leftToken (): SyntaxNode { diff --git a/packages/dbml-parse/src/core/types/symbol/symbols.ts b/packages/dbml-parse/src/core/types/symbol/symbols.ts index e5fd324b0..160ea445a 100644 --- a/packages/dbml-parse/src/core/types/symbol/symbols.ts +++ b/packages/dbml-parse/src/core/types/symbol/symbols.ts @@ -505,12 +505,27 @@ export class ColumnSymbol extends NodeSymbol { return !!s?.[SettingName.Unique]?.length; } - nullable (compiler: Compiler): boolean | undefined { - if (!this.declaration) return undefined; + // Returns whether the column is nullable, considering all constraints: + // pk, increment -> not nullable + // [not null] -> not nullable + // [null] -> nullable + // unspecified -> nullable (SQL default) + nullable (compiler: Compiler): boolean { + if (!this.declaration) return true; + if (this.pk(compiler)) return false; + if (this.increment(compiler)) return false; const s = compiler.nodeSettings(this.declaration).getFiltered(UNHANDLED); - if (s?.[SettingName.NotNull]?.length) return false; - if (s?.[SettingName.Null]?.length) return true; + return true; + } + + // Returns whether [not null] or [null] is explicitly set + // true if [not null], false if [null], undefined if unspecified + isNotNullSet (compiler: Compiler): boolean | undefined { + if (!this.declaration) return undefined; + const s = compiler.nodeSettings(this.declaration).getFiltered(UNHANDLED); + if (s?.[SettingName.NotNull]?.length) return true; + if (s?.[SettingName.Null]?.length) return false; return undefined; } diff --git a/packages/dbml-parse/src/core/types/tokens.ts b/packages/dbml-parse/src/core/types/tokens.ts index febd34487..1fc50d927 100644 --- a/packages/dbml-parse/src/core/types/tokens.ts +++ b/packages/dbml-parse/src/core/types/tokens.ts @@ -61,6 +61,7 @@ export function isOp (c?: string): boolean { case '>': case '=': case '!': + case '?': case '.': case '&': case '|': diff --git a/packages/dbml-parse/src/core/utils/validate.ts b/packages/dbml-parse/src/core/utils/validate.ts index d73fbb8ce..f682410e5 100644 --- a/packages/dbml-parse/src/core/utils/validate.ts +++ b/packages/dbml-parse/src/core/utils/validate.ts @@ -1,4 +1,5 @@ import { NUMERIC_LITERAL_PREFIX } from '@/constants'; +import { RELATIONSHIP_OPS } from '@/core/types/relation'; import { CompileError, CompileErrorCode } from '@/core/types/errors'; import { ArrayNode, @@ -85,7 +86,7 @@ export function isValidPartialInjection ( } export function isRelationshipOp (op?: string): boolean { - return op === '-' || op === '<>' || op === '>' || op === '<'; + return op !== undefined && (RELATIONSHIP_OPS as Set).has(op); } export function isValidColor (value?: SyntaxNode): boolean { diff --git a/packages/dbml-parse/src/index.ts b/packages/dbml-parse/src/index.ts index 45b7e8efc..1afc38676 100644 --- a/packages/dbml-parse/src/index.ts +++ b/packages/dbml-parse/src/index.ts @@ -67,7 +67,6 @@ export { type Ref, type RefEndpointPair, type RefEndpoint, - type RelationCardinality, type Enum, type EnumField, type TableGroup, @@ -87,6 +86,18 @@ export { type DiagramView, } from '@/core/types/schemaJson'; +export { + type RelationCardinality, + type RelationshipOp, + getRelationshipOp, + getMultiplicities, + parseCardinality, + CARDINALITY_ONE, + CARDINALITY_MAYBE, + CARDINALITY_SOME, + CARDINALITY_MANY, +} from '@/core/types/relation'; + // DiagramView types export type { DiagramViewSyncOperation, DiagramViewBlock, diff --git a/packages/dbml-parse/src/services/code_actions/provider.ts b/packages/dbml-parse/src/services/code_actions/provider.ts new file mode 100644 index 000000000..bda233c63 --- /dev/null +++ b/packages/dbml-parse/src/services/code_actions/provider.ts @@ -0,0 +1,82 @@ +import type Compiler from '@/compiler'; +import type { CompileInfo, QuickFix } from '@/core/types/errors'; +import type { + CodeActionProvider, CodeActionList, CodeAction, CodeActionContext, + TextModel, Range, CancellationToken, WorkspaceEdit, MarkerData, +} from '../types'; +import { Uri } from '../types'; + +export default class DBMLCodeActionProvider implements CodeActionProvider { + private compiler: Compiler; + + constructor (compiler: Compiler) { + this.compiler = compiler; + } + + provideCodeActions ( + model: TextModel, + _range: Range, + context: CodeActionContext, + _token: CancellationToken, + ): CodeActionList | undefined { + const markers = context.markers; + if (!markers.length) return undefined; + + const infos = this.compiler.interpretProject().getInfos(); + const actions: CodeAction[] = []; + + for (const marker of markers) { + const matchingInfos = this.findInfosForMarker(infos, marker); + for (const info of matchingInfos) { + for (const fix of info.quickFixes ?? []) { + actions.push(this.quickFixToCodeAction(fix, model, marker)); + } + } + } + + if (!actions.length) return undefined; + return { actions, dispose () {} }; + } + + private findInfosForMarker (infos: CompileInfo[], marker: MarkerData): CompileInfo[] { + return infos.filter((info) => { + const node = info.nodeOrToken; + return info.quickFixes?.length + && node.startPos.line + 1 === marker.startLineNumber + && node.startPos.column + 1 === marker.startColumn + && node.endPos.line + 1 === marker.endLineNumber + && node.endPos.column + 1 === marker.endColumn; + }); + } + + private quickFixToCodeAction (fix: QuickFix, model: TextModel, marker: MarkerData): CodeAction { + const uri = model.uri; + const resource = Uri.parse(fix.filepath.toUri({ protocol: uri.scheme })); + const edit: WorkspaceEdit = { + edits: fix.edits.map((e) => { + const startPos = model.getPositionAt(e.start); + const endPos = model.getPositionAt(e.end); + return { + resource, + textEdit: { + range: { + startLineNumber: startPos.lineNumber, + startColumn: startPos.column, + endLineNumber: endPos.lineNumber, + endColumn: endPos.column, + }, + text: e.newText, + }, + versionId: model.getVersionId(), + }; + }), + }; + return { + title: fix.title, + edit, + diagnostics: [ + marker, + ], + }; + } +} diff --git a/packages/dbml-parse/src/services/diagnostics/provider.ts b/packages/dbml-parse/src/services/diagnostics/provider.ts index 7f73c57e0..901fd1d0d 100644 --- a/packages/dbml-parse/src/services/diagnostics/provider.ts +++ b/packages/dbml-parse/src/services/diagnostics/provider.ts @@ -1,13 +1,13 @@ import type Compiler from '@/compiler'; import { Filepath } from '@/core/types/filepath'; -import type { CompileError, CompileWarning } from '@/core/types/errors'; +import type { CompileError, CompileWarning, CompileInfo } from '@/core/types/errors'; import type { SyntaxNode } from '@/core/types/nodes'; import type { SyntaxToken } from '@/core/types/tokens'; import { MarkerData, MarkerSeverity } from '@/services/types'; // This is the same format that dbdiagram-frontend uses export interface Diagnostic { - type: 'error' | 'warning'; + type: 'error' | 'warning' | 'info'; text: string; startRow: number; startColumn: number; @@ -31,6 +31,7 @@ export default class DBMLDiagnosticsProvider { return [ ...this.provideErrors(filepath), ...this.provideWarnings(filepath), + ...this.provideInfos(filepath), ]; } @@ -58,6 +59,18 @@ export default class DBMLDiagnosticsProvider { return warnings.map((warning) => this.createDiagnostic(warning, 'warning')); } + /** + * Get only infos from the current compilation + */ + provideInfos (filepath?: Filepath): Diagnostic[] { + if (!filepath) { + const infosResult = this.compiler.interpretProject().getInfos(); + return infosResult.map((info) => this.createDiagnostic(info, 'info')); + } + const infosResult = this.compiler.interpretFile(filepath).getInfos(); + return infosResult.map((info) => this.createDiagnostic(info, 'info')); + } + /** * Convert Monaco markers format (for editor integration) */ @@ -78,8 +91,8 @@ export default class DBMLDiagnosticsProvider { } private createDiagnostic ( - errorOrWarning: CompileError | CompileWarning, - severity: 'error' | 'warning', + errorOrWarning: CompileError | CompileWarning | CompileInfo, + severity: 'error' | 'warning' | 'info', ): Diagnostic { const nodeOrToken = errorOrWarning.nodeOrToken; @@ -101,8 +114,9 @@ export default class DBMLDiagnosticsProvider { }; } - private getSeverityValue (severity: 'error' | 'warning'): MarkerSeverity { - // Monaco marker severity values - return severity === 'error' ? MarkerSeverity.Error : MarkerSeverity.Warning; + private getSeverityValue (severity: 'error' | 'warning' | 'info'): MarkerSeverity { + if (severity === 'error') return MarkerSeverity.Error; + if (severity === 'warning') return MarkerSeverity.Warning; + return MarkerSeverity.Info; } } diff --git a/packages/dbml-parse/src/services/index.ts b/packages/dbml-parse/src/services/index.ts index b6e60fa07..1b44bd432 100644 --- a/packages/dbml-parse/src/services/index.ts +++ b/packages/dbml-parse/src/services/index.ts @@ -1,3 +1,4 @@ +import DBMLCodeActionProvider from './code_actions/provider'; import DBMLDefinitionProvider from './definition/provider'; import DBMLDiagnosticsProvider from './diagnostics/provider'; export type { Diagnostic } from './diagnostics/provider'; @@ -7,6 +8,7 @@ import DBMLCompletionItemProvider from './suggestions/provider'; export * from '@/services/types'; export { + DBMLCodeActionProvider, DBMLCompletionItemProvider, DBMLDefinitionProvider, DBMLReferencesProvider, diff --git a/packages/dbml-parse/src/services/types.ts b/packages/dbml-parse/src/services/types.ts index 8a41d75f4..f26a773af 100644 --- a/packages/dbml-parse/src/services/types.ts +++ b/packages/dbml-parse/src/services/types.ts @@ -82,9 +82,15 @@ export type Color = languages.IColor; // Go to definition export type DefinitionProvider = languages.DefinitionProvider; export type Definition = languages.Definition; -export type CodeActionList = languages.CodeActionList; export type SignatureHelpResult = languages.SignatureHelpResult; +// Code actions +export type CodeActionProvider = languages.CodeActionProvider; +export type CodeActionList = languages.CodeActionList; +export type CodeAction = languages.CodeAction; +export type CodeActionContext = languages.CodeActionContext; +export type WorkspaceEdit = languages.WorkspaceEdit; + // Show references export type ReferenceProvider = languages.ReferenceProvider;