dbdiagram 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,6 +55,21 @@ dbdiagram init --entry schema.dbml --workspace-url myteam --project-url myteam/m
55
55
 
56
56
  ---
57
57
 
58
+ ### Validate
59
+
60
+ Validate a local DBML file and report errors, warnings, and infos.
61
+
62
+ **Options:**
63
+
64
+ - `--json` — Output result as JSON
65
+
66
+ ```sh
67
+ dbdiagram validate schema.dbml # validate a local DBML file
68
+ dbdiagram validate schema.dbml --json # output the result as JSON
69
+ ```
70
+
71
+ ---
72
+
58
73
  ### Push
59
74
 
60
75
  Push a local DBML file to create or update a diagram on dbdiagram.io.
@@ -3,14 +3,15 @@ import { log, spinner } from '@clack/prompts';
3
3
  import { DBDOCS_CONFIGS } from '../../config.js';
4
4
  import { getCredential } from '../../config/credential-manager.js';
5
5
  import { DOCUMENT_SOURCE_TYPE } from '../../constants/document.constant.js';
6
- import { AUTH_NOT_LOGGED_IN, DBML_PARSE_ERROR, FILE_READ_ERROR, PROJECT_NAME_INVALID, PROJECT_NAME_MISSING, SOURCE_MISSING, VERSION_NAME_INVALID, WORKSPACE_NAME_INVALID, WORKSPACE_NAME_MISSING, } from '../../constants/cli-error.constant.js';
6
+ import { AUTH_NOT_LOGGED_IN, FILE_READ_ERROR, PROJECT_NAME_INVALID, PROJECT_NAME_MISSING, SOURCE_MISSING, VERSION_NAME_INVALID, WORKSPACE_NAME_INVALID, WORKSPACE_NAME_MISSING, } from '../../constants/cli-error.constant.js';
7
7
  import { CliError } from '../../errors/cli.error.js';
8
8
  import { portalIntegration } from '../../integrations/portal/portal.integration.js';
9
9
  import { trackEvent } from '../../services/telemetry.service.js';
10
10
  import { prepareBuildDocumentData } from '../../services/dbml/dbml.service.js';
11
11
  import { getCliErrorMessageFromCode, toCliError } from '../../utils/cli-error.util.js';
12
- import { formatDbmlError, getDbmlVersion } from '../../utils/dbml.util.js';
13
- import { displayError, getOutputFormat, printJson } from '../../utils/output.util.js';
12
+ import { getDbmlVersion } from '../../utils/dbml.util.js';
13
+ import { DbmlParseError } from '../../errors/dbml-parse.error.js';
14
+ import { displayDbmlParseError, displayError, getOutputFormat, printJson } from '../../utils/output.util.js';
14
15
  import { validateProjectUrlName, validateVersionName, validateWorkspaceUrlName, } from '../../utils/validation.util.js';
15
16
  const DOCUMENT_SOURCE_CLIENT_TYPE = 'dbdiagram-cli';
16
17
  export async function documentAction(options) {
@@ -84,7 +85,13 @@ export async function documentAction(options) {
84
85
  }
85
86
  catch (error) {
86
87
  process.exitCode = 1;
87
- return displayError(new CliError(DBML_PARSE_ERROR, `Failed to parse DBML: ${formatDbmlError(error)}`), getOutputFormat(options.json));
88
+ if (error instanceof DbmlParseError) {
89
+ const label = options.source.type === DOCUMENT_SOURCE_TYPE.FILE
90
+ ? options.source.value
91
+ : 'DBML';
92
+ return displayDbmlParseError(error.errors, label, getOutputFormat(options.json));
93
+ }
94
+ return displayError(toCliError(error), getOutputFormat(options.json));
88
95
  }
89
96
  const s = spinner();
90
97
  s.start('Building document on dbdocs...');
@@ -9,9 +9,9 @@ import { preparePushDiagramData } from '../services/dbml/dbml.service.js';
9
9
  import { DBDIAGRAM_CONFIGS } from '../config.js';
10
10
  import { CliError } from '../errors/cli.error.js';
11
11
  import { getCliErrorMessageFromCode, toCliError } from '../utils/cli-error.util.js';
12
- import { AUTH_NOT_LOGGED_IN, FILE_PATH_MISSING, FILE_READ_ERROR, DBML_PARSE_ERROR, } from '../constants/cli-error.constant.js';
13
- import { displayError, getOutputFormat, printJson } from '../utils/output.util.js';
14
- import { formatDbmlError } from '../utils/dbml.util.js';
12
+ import { AUTH_NOT_LOGGED_IN, FILE_PATH_MISSING, FILE_READ_ERROR, } from '../constants/cli-error.constant.js';
13
+ import { displayDbmlParseError, displayError, getOutputFormat, printJson } from '../utils/output.util.js';
14
+ import { DbmlParseError } from '../errors/dbml-parse.error.js';
15
15
  export async function pushAction(filepath, options, thisCommand) {
16
16
  const credential = getCredential();
17
17
  if (!credential) {
@@ -38,7 +38,10 @@ export async function pushAction(filepath, options, thisCommand) {
38
38
  }
39
39
  catch (error) {
40
40
  process.exitCode = 1;
41
- return displayError(new CliError(DBML_PARSE_ERROR, `Failed to parse DBML: ${formatDbmlError(error)}`), getOutputFormat(options.json));
41
+ if (error instanceof DbmlParseError) {
42
+ return displayDbmlParseError(error.errors, resolvedFilepath, getOutputFormat(options.json));
43
+ }
44
+ return displayError(toCliError(error), getOutputFormat(options.json));
42
45
  }
43
46
  const vizPath = deriveDiagramVizPathFromDBML(resolvedFilepath);
44
47
  const vizResult = await readDiagramVizFile(vizPath);
@@ -0,0 +1,51 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { log } from '@clack/prompts';
3
+ import { FILE_PATH_MISSING, FILE_READ_ERROR, VALIDATE_ERROR_HINT } from '../constants/cli-error.constant.js';
4
+ import { CliError } from '../errors/cli.error.js';
5
+ import { DbmlParseError } from '../errors/dbml-parse.error.js';
6
+ import { parseDbml } from '../services/dbml/dbml.service.js';
7
+ import { getCliErrorMessageFromCode, toCliError } from '../utils/cli-error.util.js';
8
+ import { countDiagnosticLabel, diagnosticRenderSection } from '../utils/dbml.util.js';
9
+ import { displayDbmlParseError, displayError, getOutputFormat, printJson } from '../utils/output.util.js';
10
+ export async function validateAction(filepath, options, thisCommand) {
11
+ const format = getOutputFormat(options.json);
12
+ const resolvedFilepath = thisCommand.args[0] || filepath;
13
+ if (!resolvedFilepath) {
14
+ process.exitCode = 1;
15
+ return displayError(new CliError(FILE_PATH_MISSING, getCliErrorMessageFromCode(FILE_PATH_MISSING)), format);
16
+ }
17
+ let dbml;
18
+ try {
19
+ dbml = await readFile(resolvedFilepath, 'utf8');
20
+ }
21
+ catch (error) {
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ process.exitCode = 1;
24
+ return displayError(new CliError(FILE_READ_ERROR, `Failed to read file: ${message}`), format);
25
+ }
26
+ try {
27
+ const { warnings, infos } = await parseDbml(dbml);
28
+ if (format === 'json') {
29
+ printJson({ valid: true, warnings, infos });
30
+ return;
31
+ }
32
+ const counts = [];
33
+ if (warnings.length > 0)
34
+ counts.push(`${warnings.length} ${countDiagnosticLabel(warnings.length, 'warning')}`);
35
+ if (infos.length > 0)
36
+ counts.push(`${infos.length} ${countDiagnosticLabel(infos.length, 'info')}`);
37
+ const summary = counts.length > 0 ? `${counts.join(', ')}.` : 'No issues found.';
38
+ log.success(`${resolvedFilepath} is valid. ${summary}`);
39
+ if (warnings.length > 0)
40
+ log.warn(diagnosticRenderSection(warnings, 'warning'));
41
+ if (infos.length > 0)
42
+ log.info(diagnosticRenderSection(infos, 'info'));
43
+ }
44
+ catch (error) {
45
+ process.exitCode = 1;
46
+ if (error instanceof DbmlParseError) {
47
+ return displayDbmlParseError(error.errors, resolvedFilepath, format, VALIDATE_ERROR_HINT);
48
+ }
49
+ return displayError(toCliError(error), format);
50
+ }
51
+ }
@@ -1,6 +1,7 @@
1
1
  import { registerAuthCommand } from './auth/auth.command.js';
2
2
  import { registerBuildCommand } from './build/build.command.js';
3
3
  import { registerPushCommand } from './push.command.js';
4
+ import { registerValidateCommand } from './validate.command.js';
4
5
  import { registerPullCommand } from './pull.command.js';
5
6
  import { registerDeleteCommand } from './delete.command.js';
6
7
  import { registerListCommand } from './list/list.command.js';
@@ -10,6 +11,7 @@ export function load(program) {
10
11
  registerInitCommand(program);
11
12
  registerAuthCommand(program);
12
13
  registerPushCommand(program);
14
+ registerValidateCommand(program);
13
15
  registerPullCommand(program);
14
16
  registerListCommand(program);
15
17
  registerDeleteCommand(program);
@@ -0,0 +1,11 @@
1
+ import { validateAction } from '../actions/validate.action.js';
2
+ import { validatePreAction } from '../hooks/validate.hook.js';
3
+ export function registerValidateCommand(program) {
4
+ program
5
+ .command('validate')
6
+ .description('Validate a local DBML file and report errors, warnings, and infos')
7
+ .argument('[filepath]', 'Path to the DBML file to validate')
8
+ .option('--json', 'Output as JSON')
9
+ .hook('preAction', validatePreAction)
10
+ .action(validateAction);
11
+ }
package/dist/config.js CHANGED
@@ -16,3 +16,4 @@ export const TELEMETRY_CONFIGS = {
16
16
  collectorUrl: process.env.TRACKING_COLLECTOR_URL || 'https://snowplow.holistics.io',
17
17
  appId: process.env.TRACKING_APP_ID || 'dbdiagram-cli',
18
18
  };
19
+ export const DBML_PARSE_TIMEOUT_MS = 10_000;
@@ -79,3 +79,4 @@ export const PORTAL_TO_CLI_CODE = {
79
79
  internal_server_error: INTERNAL_SERVER_ERROR,
80
80
  unexpected_request_error: NETWORK_ERROR,
81
81
  };
82
+ export const VALIDATE_ERROR_HINT = 'Fix these errors and re-run to check for warnings and infos.';
@@ -0,0 +1,9 @@
1
+ export class DbmlParseError extends Error {
2
+ errors;
3
+ constructor(errors) {
4
+ super('Failed to parse DBML');
5
+ this.errors = errors;
6
+ this.name = 'DbmlParseError';
7
+ Error.captureStackTrace?.(this, DbmlParseError);
8
+ }
9
+ }
@@ -0,0 +1,12 @@
1
+ import { getSettings } from '../config/settings-manager.js';
2
+ export function resolveValidateArgsFlags(cmd, settings) {
3
+ if (!cmd.args[0] && settings.dbml?.entry) {
4
+ cmd.args[0] = settings.dbml.entry;
5
+ }
6
+ }
7
+ export function validatePreAction(thisCommand) {
8
+ const settings = getSettings();
9
+ if (!settings)
10
+ return;
11
+ resolveValidateArgsFlags(thisCommand, settings);
12
+ }
@@ -1,25 +1,41 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { Worker } from 'node:worker_threads';
4
+ import { DbmlParseError } from '../../errors/dbml-parse.error.js';
5
+ import { DBML_PARSE_TIMEOUT_MS } from '../../config.js';
4
6
  const workerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dbml.worker.js');
5
- function parseDbml(content) {
7
+ export function parseDbml(content) {
6
8
  return new Promise((resolve, reject) => {
7
9
  const worker = new Worker(workerPath, { execArgv: process.execArgv });
10
+ let settled = false;
11
+ const timeout = setTimeout(() => {
12
+ settled = true;
13
+ worker.terminate();
14
+ reject(new Error(`DBML parser timed out after ${DBML_PARSE_TIMEOUT_MS}ms`));
15
+ }, DBML_PARSE_TIMEOUT_MS);
8
16
  worker.postMessage(content);
9
- worker.on('message', (msg) => {
17
+ worker.on('message', (message) => {
18
+ settled = true;
19
+ clearTimeout(timeout);
10
20
  worker.terminate();
11
- if (!msg.error) {
12
- resolve(msg.data);
13
- return;
14
- }
15
- const { message, diags } = msg.error;
16
- const err = new Error(message);
17
- if (diags) {
18
- err.diags = msg.error.diags;
21
+ if (message.errors)
22
+ reject(new DbmlParseError(message.errors));
23
+ else if (message.data)
24
+ resolve(message.data);
25
+ else
26
+ reject(new Error('DBML parser worker sent a message with neither data nor errors'));
27
+ });
28
+ worker.on('error', (error) => {
29
+ settled = true;
30
+ clearTimeout(timeout);
31
+ reject(error);
32
+ });
33
+ worker.on('exit', (code) => {
34
+ if (!settled) {
35
+ clearTimeout(timeout);
36
+ reject(new Error(`DBML parser worker exited unexpectedly with code ${code}`));
19
37
  }
20
- reject(err);
21
38
  });
22
- worker.on('error', reject);
23
39
  });
24
40
  }
25
41
  export function prepareBuildDocumentData(content) {
@@ -1,40 +1,61 @@
1
1
  import { parentPort } from 'node:worker_threads';
2
- import { Parser } from '@dbml/core';
2
+ import { Parser, DEFAULT_ENTRY } from '@dbml/core';
3
3
  import removeMd from 'remove-markdown';
4
+ function toDiagnostic(diagnostic) {
5
+ const { code, diagnostic: message, nodeOrToken: { startPos, endPos } } = diagnostic;
6
+ return {
7
+ code,
8
+ message,
9
+ start: {
10
+ line: startPos.line + 1,
11
+ column: startPos.column + 1,
12
+ },
13
+ end: {
14
+ line: endPos.line + 1,
15
+ column: endPos.column + 1,
16
+ },
17
+ fixes: (diagnostic.quickFixes ?? []).map((fix) => ({
18
+ title: fix.title,
19
+ edits: fix.edits.map((edit) => ({
20
+ start: edit.start,
21
+ end: edit.end,
22
+ newText: edit.newText,
23
+ })),
24
+ })),
25
+ };
26
+ }
4
27
  parentPort?.on('message', (content) => {
5
- try {
6
- const parser = new Parser();
7
- const database = parser.parse(content, 'dbmlv2');
8
- const { name: dbName = '', note: dbDescription = '' } = database;
9
- const name = dbName.trim();
10
- const description = (dbDescription ?? '').trim() !== '' ? removeMd(dbDescription).trim() : '';
11
- const shallowSchema = database.schemas.map((schema) => ({
12
- name: schema.name,
13
- tables: schema.tables.map((table) => table.name),
14
- }));
15
- const normalizedDatabase = database.normalize();
16
- const diagramViews = database.diagramViews.map((view) => ({
17
- name: view.name,
18
- visibleEntities: view.visibleEntities,
19
- }));
20
- parentPort?.postMessage({
21
- data: {
22
- name,
23
- description,
24
- shallowSchema,
25
- normalizedDatabase,
26
- database,
27
- diagramViews,
28
- },
29
- });
30
- }
31
- catch (error) {
32
- const { diags } = error;
33
- parentPort?.postMessage({
34
- error: {
35
- message: error instanceof Error ? error.message : String(error),
36
- ...(diags !== undefined ? { diags } : {}),
37
- },
38
- });
28
+ const parser = new Parser();
29
+ parser.setDbmlSource(DEFAULT_ENTRY, content);
30
+ const report = parser.DBMLCompiler.interpretFile(DEFAULT_ENTRY);
31
+ const errors = report.getErrors();
32
+ if (errors.length > 0) {
33
+ parentPort?.postMessage({ errors: errors.map(toDiagnostic) });
34
+ return;
39
35
  }
36
+ const database = Parser.parseJSONToDatabase(report.getValue() || {});
37
+ const { name: dbName = '', note: dbDescription = '' } = database;
38
+ const name = dbName.trim();
39
+ const description = (dbDescription ?? '').trim() !== '' ? removeMd(dbDescription).trim() : '';
40
+ const shallowSchema = database.schemas.map((schema) => ({
41
+ name: schema.name,
42
+ tables: schema.tables.map((table) => table.name),
43
+ }));
44
+ const normalizedDatabase = database.normalize();
45
+ const diagramViews = database.diagramViews.map((view) => ({
46
+ name: view.name,
47
+ visibleEntities: view.visibleEntities,
48
+ }));
49
+ parentPort?.postMessage({
50
+ data: {
51
+ name,
52
+ description,
53
+ shallowSchema,
54
+ normalizedDatabase,
55
+ database,
56
+ diagramViews,
57
+ warnings: report.getWarnings().map(toDiagnostic),
58
+ infos: report.getInfos().map(toDiagnostic),
59
+ },
60
+ });
40
61
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -1,30 +1,26 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import path from 'node:path';
4
+ import pluralize from 'pluralize';
4
5
  export function getDbmlVersion() {
5
6
  const require = createRequire(import.meta.url);
6
7
  const entry = require.resolve('@dbml/core');
7
8
  const pkg = JSON.parse(readFileSync(path.resolve(path.dirname(entry), '..', 'package.json'), 'utf8'));
8
9
  return pkg.version;
9
10
  }
10
- export function parseDbmlDiags(error) {
11
- if (!error || typeof error !== 'object' || !Array.isArray(error.diags)) {
12
- return [];
13
- }
14
- const { diags } = error;
15
- return diags
16
- .map((d) => {
17
- const loc = d.location?.start;
18
- const locStr = loc ? ` (line ${loc.line}, column ${loc.column})` : '';
19
- return d.message ? `${d.message}${locStr}` : '';
20
- })
21
- .filter(Boolean);
22
- }
23
- export function formatDbmlError(error) {
24
- const diags = parseDbmlDiags(error);
25
- if (diags.length > 0)
26
- return `\n - ${diags.join('\n - ')}`;
27
- if (error instanceof Error)
28
- return error.message;
29
- return String(error);
30
- }
11
+ export const countDiagnosticLabel = (count, noun) => {
12
+ return pluralize(noun, count);
13
+ };
14
+ export const diagnosticRenderLine = (diagnostic) => {
15
+ const location = `(line ${diagnostic.start.line}, column ${diagnostic.start.column})`;
16
+ return [
17
+ ` - ${diagnostic.message} ${location} [${diagnostic.code}]`,
18
+ ...diagnostic.fixes.map((fix) => ` fix: ${fix.title}`),
19
+ ];
20
+ };
21
+ export const diagnosticRenderSection = (diagnostics, noun) => {
22
+ return [
23
+ `${countDiagnosticLabel(diagnostics.length, noun)}:`,
24
+ ...diagnostics.flatMap(diagnosticRenderLine),
25
+ ].join('\n');
26
+ };
@@ -1,6 +1,8 @@
1
1
  import Table from 'cli-table3';
2
2
  import { log } from '@clack/prompts';
3
+ import { DBML_PARSE_ERROR } from '../constants/cli-error.constant.js';
3
4
  import { logConsole } from './logger.util.js';
5
+ import { countDiagnosticLabel, diagnosticRenderLine } from './dbml.util.js';
4
6
  export const getOutputFormat = (json) => (json ? 'json' : 'plain');
5
7
  export function printJson(data) {
6
8
  logConsole(JSON.stringify(data, null, 2));
@@ -47,3 +49,14 @@ export function printTable(data, config) {
47
49
  }
48
50
  logConsole(table.toString());
49
51
  }
52
+ export const displayDbmlParseError = (errors, label, format, hint) => {
53
+ const message = `Failed to parse ${label}. ${errors.length} ${countDiagnosticLabel(errors.length, 'error')}.`;
54
+ if (format === 'json') {
55
+ printJson({ errorCode: DBML_PARSE_ERROR, message, errors });
56
+ return;
57
+ }
58
+ const lines = [message, ...errors.flatMap(diagnosticRenderLine)];
59
+ if (hint)
60
+ lines.push('', hint);
61
+ log.error(lines.join('\n'));
62
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dbdiagram",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "main": "dist/index.js",
5
5
  "bin": {
6
6
  "dbdiagram": "dist/index.js"
@@ -39,12 +39,14 @@
39
39
  "dotenv": "^16.4.0",
40
40
  "luxon": "^3.7.2",
41
41
  "open": "^11.0.0",
42
+ "pluralize": "^8.0.0",
42
43
  "remove-markdown": "^0.6.4"
43
44
  },
44
45
  "devDependencies": {
45
46
  "@eslint/js": "^10.0.1",
46
47
  "@types/luxon": "^3.7.1",
47
48
  "@types/node": "^25.7.0",
49
+ "@types/pluralize": "^0.0.33",
48
50
  "@vitest/coverage-v8": "^4.1.0",
49
51
  "danger": "^13.0.10",
50
52
  "danger-plugin-istanbul-coverage": "^1.6.2",