feat(import-db): add DBML syntax to import database dialog (#768)

* feat(editor): add import DBML syntax in import database dialog

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
This commit is contained in:
Jonathan Fishner
2025-09-15 19:01:50 +03:00
committed by GitHub
parent 8954d893bb
commit af3638da7a
17 changed files with 668 additions and 170 deletions

View File

@@ -38,7 +38,7 @@ export interface CodeSnippetProps {
className?: string;
code: string;
codeToCopy?: string;
language?: 'sql' | 'shell';
language?: 'sql' | 'shell' | 'dbml';
loading?: boolean;
autoScroll?: boolean;
isComplete?: boolean;

View File

@@ -9,12 +9,14 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'comment', foreground: '6A9955' }, // Comments
{ token: 'keyword', foreground: '569CD6' }, // Table, Ref keywords
{ token: 'string', foreground: 'CE9178' }, // Strings
{ token: 'annotation', foreground: '9CDCFE' }, // [annotations]
{ token: 'delimiter', foreground: 'D4D4D4' }, // Braces {}
{ token: 'operator', foreground: 'D4D4D4' }, // Operators
{ token: 'datatype', foreground: '4EC9B0' }, // Data types
{ token: 'type', foreground: '4EC9B0' }, // Data types
{ token: 'identifier', foreground: '9CDCFE' }, // Field names
],
colors: {},
});
@@ -23,12 +25,14 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
base: 'vs',
inherit: true,
rules: [
{ token: 'comment', foreground: '008000' }, // Comments
{ token: 'keyword', foreground: '0000FF' }, // Table, Ref keywords
{ token: 'string', foreground: 'A31515' }, // Strings
{ token: 'annotation', foreground: '001080' }, // [annotations]
{ token: 'delimiter', foreground: '000000' }, // Braces {}
{ token: 'operator', foreground: '000000' }, // Operators
{ token: 'type', foreground: '267F99' }, // Data types
{ token: 'identifier', foreground: '001080' }, // Field names
],
colors: {},
});
@@ -37,23 +41,59 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
const datatypePattern = dataTypesNames.join('|');
monaco.languages.setMonarchTokensProvider('dbml', {
keywords: ['Table', 'Ref', 'Indexes', 'Note', 'Enum'],
keywords: ['Table', 'Ref', 'Indexes', 'Note', 'Enum', 'enum'],
datatypes: dataTypesNames,
operators: ['>', '<', '-'],
tokenizer: {
root: [
// Comments
[/\/\/.*$/, 'comment'],
// Keywords - case insensitive
[
/\b([Tt][Aa][Bb][Ll][Ee]|[Ee][Nn][Uu][Mm]|[Rr][Ee][Ff]|[Ii][Nn][Dd][Ee][Xx][Ee][Ss]|[Nn][Oo][Tt][Ee])\b/,
'keyword',
],
// Annotations in brackets
[/\[.*?\]/, 'annotation'],
// Strings
[/'''/, 'string', '@tripleQuoteString'],
[/".*?"/, 'string'],
[/'.*?'/, 'string'],
[/"([^"\\]|\\.)*$/, 'string.invalid'], // non-terminated string
[/'([^'\\]|\\.)*$/, 'string.invalid'], // non-terminated string
[/"/, 'string', '@string_double'],
[/'/, 'string', '@string_single'],
[/`.*?`/, 'string'],
[/[{}]/, 'delimiter'],
[/[<>]/, 'operator'],
[new RegExp(`\\b(${datatypePattern})\\b`, 'i'), 'type'], // Added 'i' flag for case-insensitive matching
// Delimiters and operators
[/[{}()]/, 'delimiter'],
[/[<>-]/, 'operator'],
[/:/, 'delimiter'],
// Data types
[new RegExp(`\\b(${datatypePattern})\\b`, 'i'), 'type'],
// Numbers
[/\d+/, 'number'],
// Identifiers
[/[a-zA-Z_]\w*/, 'identifier'],
],
string_double: [
[/[^\\"]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, 'string', '@pop'],
],
string_single: [
[/[^\\']+/, 'string'],
[/\\./, 'string.escape'],
[/'/, 'string', '@pop'],
],
tripleQuoteString: [
[/[^']+/, 'string'],
[/'''/, 'string', '@pop'],

View File

@@ -42,6 +42,10 @@ import {
type ValidationResult,
} from '@/lib/data/sql-import/sql-validator';
import { SQLValidationStatus } from './sql-validation-status';
import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language';
import type { ImportMethod } from '@/lib/import-method/import-method';
import { detectImportMethod } from '@/lib/import-method/detect-import-method';
import { verifyDBML } from '@/lib/dbml/dbml-import/verify-dbml';
const calculateContentSizeMB = (content: string): number => {
return content.length / (1024 * 1024); // Convert to MB
@@ -55,49 +59,6 @@ const calculateIsLargeFile = (content: string): boolean => {
const errorScriptOutputMessage =
'Invalid JSON. Please correct it or contact us at support@chartdb.io for help.';
// Helper to detect if content is likely SQL DDL or JSON
const detectContentType = (content: string): 'query' | 'ddl' | null => {
if (!content || content.trim().length === 0) return null;
// Common SQL DDL keywords
const ddlKeywords = [
'CREATE TABLE',
'ALTER TABLE',
'DROP TABLE',
'CREATE INDEX',
'CREATE VIEW',
'CREATE PROCEDURE',
'CREATE FUNCTION',
'CREATE SCHEMA',
'CREATE DATABASE',
];
const upperContent = content.toUpperCase();
// Check for SQL DDL patterns
const hasDDLKeywords = ddlKeywords.some((keyword) =>
upperContent.includes(keyword)
);
if (hasDDLKeywords) return 'ddl';
// Check if it looks like JSON
try {
// Just check structure, don't need full parse for detection
if (
(content.trim().startsWith('{') && content.trim().endsWith('}')) ||
(content.trim().startsWith('[') && content.trim().endsWith(']'))
) {
return 'query';
}
} catch (error) {
// Not valid JSON, might be partial
console.error('Error detecting content type:', error);
}
// If we can't confidently detect, return null
return null;
};
export interface ImportDatabaseProps {
goBack?: () => void;
onImport: () => void;
@@ -111,8 +72,8 @@ export interface ImportDatabaseProps {
>;
keepDialogAfterImport?: boolean;
title: string;
importMethod: 'query' | 'ddl';
setImportMethod: (method: 'query' | 'ddl') => void;
importMethod: ImportMethod;
setImportMethod: (method: ImportMethod) => void;
}
export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
@@ -152,9 +113,9 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
setShowCheckJsonButton(false);
}, [importMethod, setScriptResult]);
// Check if the ddl is valid
// Check if the ddl or dbml is valid
useEffect(() => {
if (importMethod !== 'ddl') {
if (importMethod === 'query') {
setSqlValidation(null);
setShowAutoFixButton(false);
return;
@@ -163,9 +124,48 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
if (!scriptResult.trim()) {
setSqlValidation(null);
setShowAutoFixButton(false);
setErrorMessage('');
return;
}
if (importMethod === 'dbml') {
// Validate DBML by parsing it
const validateResponse = verifyDBML(scriptResult);
if (!validateResponse.hasError) {
setErrorMessage('');
setSqlValidation({
isValid: true,
errors: [],
warnings: [],
});
} else {
let errorMsg = 'Invalid DBML syntax';
let line: number = 1;
if (validateResponse.parsedError) {
errorMsg = validateResponse.parsedError.message;
line = validateResponse.parsedError.line;
}
setSqlValidation({
isValid: false,
errors: [
{
message: errorMsg,
line: line,
type: 'syntax' as const,
},
],
warnings: [],
});
setErrorMessage(errorMsg);
}
setShowAutoFixButton(false);
return;
}
// SQL validation
// First run our validation based on database type
const validation = validateSQL(scriptResult, databaseType);
setSqlValidation(validation);
@@ -338,7 +338,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
const isLargeFile = calculateIsLargeFile(content);
// First, detect content type to determine if we should switch modes
const detectedType = detectContentType(content);
const detectedType = detectImportMethod(content);
if (detectedType && detectedType !== importMethod) {
// Switch to the detected mode immediately
setImportMethod(detectedType);
@@ -352,7 +352,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
?.run();
}, 100);
}
// For DDL mode, do NOT format as it can break the SQL
// For DDL and DBML modes, do NOT format as it can break the syntax
} else {
// Content type didn't change, apply formatting based on current mode
if (importMethod === 'query' && !isLargeFile) {
@@ -363,7 +363,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
?.run();
}, 100);
}
// For DDL mode or large files, do NOT format
// For DDL and DBML modes or large files, do NOT format
}
});
@@ -410,16 +410,25 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
<div className="w-full text-center text-xs text-muted-foreground">
{importMethod === 'query'
? 'Smart Query Output'
: 'SQL Script'}
: importMethod === 'dbml'
? 'DBML Script'
: 'SQL Script'}
</div>
<div className="flex-1 overflow-hidden">
<Suspense fallback={<Spinner />}>
<Editor
value={scriptResult}
onChange={debouncedHandleInputChange}
language={importMethod === 'query' ? 'json' : 'sql'}
language={
importMethod === 'query'
? 'json'
: importMethod === 'dbml'
? 'dbml'
: 'sql'
}
loading={<Spinner />}
onMount={handleEditorDidMount}
beforeMount={setupDBMLLanguage}
theme={
effectiveTheme === 'dark'
? 'dbml-dark'
@@ -430,7 +439,6 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
minimap: { enabled: false },
scrollBeyondLastLine: false,
automaticLayout: true,
glyphMargin: false,
lineNumbers: 'on',
guides: {
indentation: false,
@@ -455,7 +463,9 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
</Suspense>
</div>
{errorMessage || (importMethod === 'ddl' && sqlValidation) ? (
{errorMessage ||
((importMethod === 'ddl' || importMethod === 'dbml') &&
sqlValidation) ? (
<SQLValidationStatus
validation={sqlValidation}
errorMessage={errorMessage}

View File

@@ -15,9 +15,11 @@ import {
AvatarImage,
} from '@/components/avatar/avatar';
import { useTranslation } from 'react-i18next';
import { Code } from 'lucide-react';
import { Code, FileCode } from 'lucide-react';
import { SmartQueryInstructions } from './instructions/smart-query-instructions';
import { DDLInstructions } from './instructions/ddl-instructions';
import { DBMLInstructions } from './instructions/dbml-instructions';
import type { ImportMethod } from '@/lib/import-method/import-method';
const DatabasesWithoutDDLInstructions: DatabaseType[] = [
DatabaseType.CLICKHOUSE,
@@ -30,8 +32,8 @@ export interface InstructionsSectionProps {
setDatabaseEdition: React.Dispatch<
React.SetStateAction<DatabaseEdition | undefined>
>;
importMethod: 'query' | 'ddl';
setImportMethod: (method: 'query' | 'ddl') => void;
importMethod: ImportMethod;
setImportMethod: (method: ImportMethod) => void;
showSSMSInfoDialog: boolean;
setShowSSMSInfoDialog: (show: boolean) => void;
}
@@ -125,9 +127,9 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
className="ml-1 flex-wrap justify-start gap-2"
value={importMethod}
onValueChange={(value) => {
let selectedImportMethod: 'query' | 'ddl' = 'query';
let selectedImportMethod: ImportMethod = 'query';
if (value) {
selectedImportMethod = value as 'query' | 'ddl';
selectedImportMethod = value as ImportMethod;
}
setImportMethod(selectedImportMethod);
@@ -150,10 +152,20 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
>
<Avatar className="size-4 rounded-none">
<Code size={16} />
<FileCode size={16} />
</Avatar>
SQL Script
</ToggleGroupItem>
<ToggleGroupItem
value="dbml"
variant="outline"
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
>
<Avatar className="size-4 rounded-none">
<Code size={16} />
</Avatar>
DBML
</ToggleGroupItem>
</ToggleGroup>
</div>
)}
@@ -167,11 +179,16 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
showSSMSInfoDialog={showSSMSInfoDialog}
setShowSSMSInfoDialog={setShowSSMSInfoDialog}
/>
) : (
) : importMethod === 'ddl' ? (
<DDLInstructions
databaseType={databaseType}
databaseEdition={databaseEdition}
/>
) : (
<DBMLInstructions
databaseType={databaseType}
databaseEdition={databaseEdition}
/>
)}
</div>
</div>

View File

@@ -0,0 +1,47 @@
import React from 'react';
import type { DatabaseType } from '@/lib/domain/database-type';
import type { DatabaseEdition } from '@/lib/domain/database-edition';
import { CodeSnippet } from '@/components/code-snippet/code-snippet';
import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language';
export interface DBMLInstructionsProps {
databaseType: DatabaseType;
databaseEdition?: DatabaseEdition;
}
export const DBMLInstructions: React.FC<DBMLInstructionsProps> = () => {
return (
<>
<div className="flex flex-col gap-1 text-sm text-primary">
<div>
Paste your DBML (Database Markup Language) schema definition
here
</div>
</div>
<div className="flex h-64 flex-col gap-1 text-sm text-primary">
<h4 className="text-xs font-medium">Example:</h4>
<CodeSnippet
className="h-full"
allowCopy={false}
editorProps={{
beforeMount: setupDBMLLanguage,
}}
code={`Table users {
id int [pk]
username varchar
email varchar
}
Table posts {
id int [pk]
user_id int [ref: > users.id]
title varchar
content text
}`}
language={'dbml'}
/>
</div>
</>
);
};

View File

@@ -73,7 +73,7 @@ export const SQLValidationStatus: React.FC<SQLValidationStatusProps> = ({
{hasErrors ? (
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950">
<ScrollArea className="h-24">
<ScrollArea className="h-fit max-h-24">
<div className="space-y-3 p-3 pt-2 text-red-700 dark:text-red-300">
{validation?.errors
.slice(0, 3)
@@ -137,7 +137,7 @@ export const SQLValidationStatus: React.FC<SQLValidationStatusProps> = ({
{hasWarnings && !hasErrors ? (
<div className="rounded-md border border-sky-200 bg-sky-50 dark:border-sky-800 dark:bg-sky-950">
<ScrollArea className="h-24">
<ScrollArea className="h-fit max-h-24">
<div className="space-y-3 p-3 pt-2 text-sky-700 dark:text-sky-300">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-sky-700 dark:text-sky-300" />

View File

@@ -22,6 +22,11 @@ import { sqlImportToDiagram } from '@/lib/data/sql-import';
import type { SelectedTable } from '@/lib/data/import-metadata/filter-metadata';
import { filterMetadataByTables } from '@/lib/data/import-metadata/filter-metadata';
import { MAX_TABLES_WITHOUT_SHOWING_FILTER } from '../common/select-tables/constants';
import {
defaultDBMLDiagramName,
importDBMLToDiagram,
} from '@/lib/dbml/dbml-import/dbml-import';
import type { ImportMethod } from '@/lib/import-method/import-method';
export interface CreateDiagramDialogProps extends BaseDialogProps {}
@@ -30,7 +35,7 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
}) => {
const { diagramId } = useChartDB();
const { t } = useTranslation();
const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query');
const [importMethod, setImportMethod] = useState<ImportMethod>('query');
const [databaseType, setDatabaseType] = useState<DatabaseType>(
DatabaseType.GENERIC
);
@@ -89,6 +94,14 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
sourceDatabaseType: databaseType,
targetDatabaseType: databaseType,
});
} else if (importMethod === 'dbml') {
diagram = await importDBMLToDiagram(scriptResult, {
databaseType,
});
// Update the diagram name if it's the default
if (diagram.name === defaultDBMLDiagramName) {
diagram.name = `Diagram ${diagramNumber}`;
}
} else {
let metadata: DatabaseMetadata | undefined = databaseMetadata;
@@ -171,7 +184,7 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
try {
setIsParsingMetadata(true);
if (importMethod === 'ddl') {
if (importMethod === 'ddl' || importMethod === 'dbml') {
await importNewDiagram();
} else {
// Parse metadata asynchronously to avoid blocking the UI

View File

@@ -15,6 +15,8 @@ import { useReactFlow } from '@xyflow/react';
import type { BaseDialogProps } from '../common/base-dialog-props';
import { useAlert } from '@/context/alert-context/alert-context';
import { sqlImportToDiagram } from '@/lib/data/sql-import';
import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import';
import type { ImportMethod } from '@/lib/import-method/import-method';
export interface ImportDatabaseDialogProps extends BaseDialogProps {
databaseType: DatabaseType;
@@ -24,7 +26,7 @@ export const ImportDatabaseDialog: React.FC<ImportDatabaseDialogProps> = ({
dialog,
databaseType,
}) => {
const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query');
const [importMethod, setImportMethod] = useState<ImportMethod>('query');
const { closeImportDatabaseDialog } = useDialog();
const { showAlert } = useAlert();
const {
@@ -65,6 +67,10 @@ export const ImportDatabaseDialog: React.FC<ImportDatabaseDialogProps> = ({
sourceDatabaseType: databaseType,
targetDatabaseType: databaseType,
});
} else if (importMethod === 'dbml') {
diagram = await importDBMLToDiagram(scriptResult, {
databaseType,
});
} else {
const databaseMetadata: DatabaseMetadata =
loadDatabaseMetadata(scriptResult);

View File

@@ -23,24 +23,19 @@ import { useTranslation } from 'react-i18next';
import { Editor } from '@/components/code-snippet/code-snippet';
import { useTheme } from '@/hooks/use-theme';
import { AlertCircle } from 'lucide-react';
import {
importDBMLToDiagram,
sanitizeDBML,
preprocessDBML,
} from '@/lib/dbml/dbml-import/dbml-import';
import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import';
import { useChartDB } from '@/hooks/use-chartdb';
import { Parser } from '@dbml/core';
import { useCanvas } from '@/hooks/use-canvas';
import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language';
import type { DBTable } from '@/lib/domain/db-table';
import { useToast } from '@/components/toast/use-toast';
import { Spinner } from '@/components/spinner/spinner';
import { debounce } from '@/lib/utils';
import { parseDBMLError } from '@/lib/dbml/dbml-import/dbml-import-error';
import {
clearErrorHighlight,
highlightErrorLine,
} from '@/components/code-snippet/dbml/utils';
import { verifyDBML } from '@/lib/dbml/dbml-import/verify-dbml';
export interface ImportDBMLDialogProps extends BaseDialogProps {
withCreateEmptyDiagram?: boolean;
@@ -93,6 +88,7 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
relationships,
removeTables,
removeRelationships,
databaseType,
} = useChartDB();
const { reorderTables } = useCanvas();
const [reorder, setReorder] = useState(false);
@@ -126,16 +122,15 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
setErrorMessage(undefined);
clearDecorations();
if (!content.trim()) return;
if (!content.trim()) {
return;
}
try {
const preprocessedContent = preprocessDBML(content);
const sanitizedContent = sanitizeDBML(preprocessedContent);
const parser = new Parser();
parser.parse(sanitizedContent, 'dbmlv2');
} catch (e) {
const parsedError = parseDBMLError(e);
if (parsedError) {
const validateResponse = verifyDBML(content);
if (validateResponse.hasError) {
if (validateResponse.parsedError) {
const parsedError = validateResponse.parsedError;
setErrorMessage(
t('import_dbml_dialog.error.description') +
` (1 error found - in line ${parsedError.line})`
@@ -147,9 +142,7 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
decorationsCollection.current,
});
} else {
setErrorMessage(
e instanceof Error ? e.message : JSON.stringify(e)
);
setErrorMessage(validateResponse.errorText);
}
}
},
@@ -188,7 +181,9 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
if (!dbmlContent.trim() || errorMessage) return;
try {
const importedDiagram = await importDBMLToDiagram(dbmlContent);
const importedDiagram = await importDBMLToDiagram(dbmlContent, {
databaseType,
});
const tableIdsToRemove = tables
.filter((table) =>
importedDiagram.tables?.some(
@@ -267,6 +262,7 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
toast,
setReorder,
t,
databaseType,
]);
return (

View File

@@ -5,7 +5,7 @@ import {
databaseTypesWithCommentSupport,
} from '@/lib/domain/database-type';
import type { DBTable } from '@/lib/domain/db-table';
import type { DataType } from '../data-types/data-types';
import { dataTypeMap, type DataType } from '../data-types/data-types';
import { generateCacheKey, getFromCache, setInCache } from './export-sql-cache';
import { exportMSSQL } from './export-per-type/mssql';
import { exportPostgreSQL } from './export-per-type/postgresql';
@@ -314,11 +314,26 @@ export const exportBaseSQL = ({
sqlScript += `(1)`;
}
// Add precision and scale for numeric types
if (field.precision && field.scale) {
sqlScript += `(${field.precision}, ${field.scale})`;
} else if (field.precision) {
sqlScript += `(${field.precision})`;
// Add precision and scale for numeric types only
const precisionAndScaleTypes = dataTypeMap[targetDatabaseType]
.filter(
(t) =>
t.fieldAttributes?.precision && t.fieldAttributes?.scale
)
.map((t) => t.name);
const isNumericType = precisionAndScaleTypes.some(
(t) =>
field.type.name.toLowerCase().includes(t) ||
typeName.toLowerCase().includes(t)
);
if (isNumericType) {
if (field.precision && field.scale) {
sqlScript += `(${field.precision}, ${field.scale})`;
} else if (field.precision) {
sqlScript += `(${field.precision})`;
}
}
// Handle NOT NULL constraint

View File

@@ -1,66 +0,0 @@
import { describe, it } from 'vitest';
describe('node-sql-parser - CREATE TYPE handling', () => {
it('should show exact parser error for CREATE TYPE', async () => {
const { Parser } = await import('node-sql-parser');
const parser = new Parser();
const parserOpts = {
database: 'PostgreSQL',
};
console.log('\n=== Testing CREATE TYPE statement ===');
const createTypeSQL = `CREATE TYPE spell_element AS ENUM ('fire', 'water', 'earth', 'air');`;
try {
parser.astify(createTypeSQL, parserOpts);
console.log('CREATE TYPE parsed successfully');
} catch (error) {
console.log('CREATE TYPE parse error:', (error as Error).message);
}
console.log('\n=== Testing CREATE EXTENSION statement ===');
const createExtensionSQL = `CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`;
try {
parser.astify(createExtensionSQL, parserOpts);
console.log('CREATE EXTENSION parsed successfully');
} catch (error) {
console.log(
'CREATE EXTENSION parse error:',
(error as Error).message
);
}
console.log('\n=== Testing CREATE TABLE with custom type ===');
const createTableWithTypeSQL = `CREATE TABLE wizards (
id UUID PRIMARY KEY,
element spell_element DEFAULT 'fire'
);`;
try {
parser.astify(createTableWithTypeSQL, parserOpts);
console.log('CREATE TABLE with custom type parsed successfully');
} catch (error) {
console.log(
'CREATE TABLE with custom type parse error:',
(error as Error).message
);
}
console.log('\n=== Testing CREATE TABLE with standard types only ===');
const createTableStandardSQL = `CREATE TABLE wizards (
id UUID PRIMARY KEY,
element VARCHAR(20) DEFAULT 'fire'
);`;
try {
parser.astify(createTableStandardSQL, parserOpts);
console.log('CREATE TABLE with standard types parsed successfully');
} catch (error) {
console.log(
'CREATE TABLE with standard types parse error:',
(error as Error).message
);
}
});
});

View File

@@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest';
import { DatabaseType } from '@/lib/domain/database-type';
import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import';
// This test verifies the DBML integration without UI components
describe('DBML Integration Tests', () => {
it('should handle DBML import in create diagram flow', async () => {
const dbmlContent = `
Table users {
id uuid [pk, not null]
email varchar [unique, not null]
created_at timestamp
}
Table posts {
id uuid [pk]
title varchar
content text
user_id uuid [ref: > users.id]
created_at timestamp
}
Table comments {
id uuid [pk]
content text
post_id uuid [ref: > posts.id]
user_id uuid [ref: > users.id]
}
// This will be ignored
TableGroup "Content" {
posts
comments
}
// This will be ignored too
Note test_note {
'This is a test note'
}`;
const diagram = await importDBMLToDiagram(dbmlContent);
// Verify basic structure
expect(diagram).toBeDefined();
expect(diagram.tables).toHaveLength(3);
expect(diagram.relationships).toHaveLength(3);
// Verify tables
const tableNames = diagram.tables?.map((t) => t.name).sort();
expect(tableNames).toEqual(['comments', 'posts', 'users']);
// Verify users table
const usersTable = diagram.tables?.find((t) => t.name === 'users');
expect(usersTable).toBeDefined();
expect(usersTable?.fields).toHaveLength(3);
const emailField = usersTable?.fields.find((f) => f.name === 'email');
expect(emailField?.unique).toBe(true);
expect(emailField?.nullable).toBe(false);
// Verify relationships
// There should be 3 relationships total
expect(diagram.relationships).toHaveLength(3);
// Find the relationship from users to posts (DBML ref is: posts.user_id > users.id)
// This creates a relationship FROM users TO posts (one user has many posts)
const postsTable = diagram.tables?.find((t) => t.name === 'posts');
const usersTableId = usersTable?.id;
const userPostRelation = diagram.relationships?.find(
(r) =>
r.sourceTableId === usersTableId &&
r.targetTableId === postsTable?.id
);
expect(userPostRelation).toBeDefined();
expect(userPostRelation?.sourceCardinality).toBe('one');
expect(userPostRelation?.targetCardinality).toBe('many');
});
it('should handle DBML with special features', async () => {
const dbmlContent = `
// Enum will be converted to varchar
Table users {
id int [pk]
status enum
tags text[] // Array will be converted to text
favorite_product_id int
}
Table products [headercolor: #FF0000] {
id int [pk]
name varchar
price decimal(10,2)
}
Ref: products.id < users.favorite_product_id`;
const diagram = await importDBMLToDiagram(dbmlContent);
expect(diagram.tables).toHaveLength(2);
// Check enum conversion
const usersTable = diagram.tables?.find((t) => t.name === 'users');
const statusField = usersTable?.fields.find((f) => f.name === 'status');
expect(statusField?.type.id).toBe('varchar');
// Check array type conversion
const tagsField = usersTable?.fields.find((f) => f.name === 'tags');
expect(tagsField?.type.id).toBe('text');
// Check that header color was removed
const productsTable = diagram.tables?.find(
(t) => t.name === 'products'
);
expect(productsTable).toBeDefined();
expect(productsTable?.name).toBe('products');
});
it('should handle empty or invalid DBML gracefully', async () => {
// Empty DBML
const emptyDiagram = await importDBMLToDiagram('');
expect(emptyDiagram.tables).toHaveLength(0);
expect(emptyDiagram.relationships).toHaveLength(0);
// Only comments
const commentDiagram = await importDBMLToDiagram('// Just a comment');
expect(commentDiagram.tables).toHaveLength(0);
expect(commentDiagram.relationships).toHaveLength(0);
});
it('should preserve diagram metadata when importing DBML', async () => {
const dbmlContent = `Table test {
id int [pk]
}`;
const diagram = await importDBMLToDiagram(dbmlContent);
// Default values
expect(diagram.name).toBe('DBML Import');
expect(diagram.databaseType).toBe(DatabaseType.GENERIC);
// These can be overridden by the dialog
diagram.name = 'My Custom Diagram';
diagram.databaseType = DatabaseType.POSTGRESQL;
expect(diagram.name).toBe('My Custom Diagram');
expect(diagram.databaseType).toBe(DatabaseType.POSTGRESQL);
});
});

View File

@@ -15,6 +15,8 @@ import {
type DBCustomType,
} from '@/lib/domain/db-custom-type';
export const defaultDBMLDiagramName = 'DBML Import';
// Preprocess DBML to handle unsupported features
export const preprocessDBML = (content: string): string => {
let processed = content;
@@ -196,7 +198,7 @@ export const importDBMLToDiagram = async (
if (!dbmlContent.trim()) {
return {
id: generateDiagramId(),
name: 'DBML Import',
name: defaultDBMLDiagramName,
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
tables: [],
relationships: [],
@@ -214,7 +216,7 @@ export const importDBMLToDiagram = async (
if (!sanitizedContent.trim()) {
return {
id: generateDiagramId(),
name: 'DBML Import',
name: defaultDBMLDiagramName,
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
tables: [],
relationships: [],
@@ -229,7 +231,7 @@ export const importDBMLToDiagram = async (
if (!parsedData.schemas || parsedData.schemas.length === 0) {
return {
id: generateDiagramId(),
name: 'DBML Import',
name: defaultDBMLDiagramName,
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
tables: [],
relationships: [],
@@ -734,7 +736,7 @@ export const importDBMLToDiagram = async (
return {
id: generateDiagramId(),
name: 'DBML Import',
name: defaultDBMLDiagramName,
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
tables,
relationships,

View File

@@ -0,0 +1,52 @@
import { Parser } from '@dbml/core';
import { preprocessDBML, sanitizeDBML } from './dbml-import';
import type { DBMLError } from './dbml-import-error';
import { parseDBMLError } from './dbml-import-error';
export const verifyDBML = (
content: string
):
| {
hasError: true;
error: unknown;
parsedError?: DBMLError;
errorText: string;
}
| {
hasError: false;
} => {
try {
const preprocessedContent = preprocessDBML(content);
const sanitizedContent = sanitizeDBML(preprocessedContent);
const parser = new Parser();
parser.parse(sanitizedContent, 'dbmlv2');
} catch (e) {
const parsedError = parseDBMLError(e);
if (parsedError) {
return {
hasError: true,
parsedError: parsedError,
error: e,
errorText: parsedError.message,
};
} else {
if (e instanceof Error) {
return {
hasError: true,
error: e,
errorText: e.message,
};
}
return {
hasError: true,
error: e,
errorText: JSON.stringify(e),
};
}
}
return {
hasError: false,
};
};

View File

@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import { detectImportMethod } from '../detect-import-method';
describe('detectImportMethod', () => {
describe('DBML detection', () => {
it('should detect DBML with Table definition', () => {
const content = `Table users {
id int [pk]
name varchar
}`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should detect DBML with Ref definition', () => {
const content = `Table posts {
user_id int
}
Ref: posts.user_id > users.id`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should detect DBML with pk attribute', () => {
const content = `id integer [pk]`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should detect DBML with ref attribute', () => {
const content = `user_id int [ref: > users.id]`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should detect DBML with Enum definition', () => {
const content = `Enum status {
active
inactive
}`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should detect DBML with TableGroup', () => {
const content = `TableGroup commerce {
users
orders
}`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should detect DBML with Note', () => {
const content = `Note project_note {
'This is a note about the project'
}`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should prioritize DBML over SQL when both patterns exist', () => {
const content = `CREATE TABLE test (id int);
Table users {
id int [pk]
}`;
expect(detectImportMethod(content)).toBe('dbml');
});
});
describe('SQL DDL detection', () => {
it('should detect CREATE TABLE statement', () => {
const content = `CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(255)
);`;
expect(detectImportMethod(content)).toBe('ddl');
});
it('should detect ALTER TABLE statement', () => {
const content = `ALTER TABLE users ADD COLUMN email VARCHAR(255);`;
expect(detectImportMethod(content)).toBe('ddl');
});
it('should detect DROP TABLE statement', () => {
const content = `DROP TABLE IF EXISTS users;`;
expect(detectImportMethod(content)).toBe('ddl');
});
it('should detect CREATE INDEX statement', () => {
const content = `CREATE INDEX idx_users_email ON users(email);`;
expect(detectImportMethod(content)).toBe('ddl');
});
it('should detect multiple DDL statements', () => {
const content = `CREATE TABLE users (id INT);
CREATE TABLE posts (id INT);
ALTER TABLE posts ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);`;
expect(detectImportMethod(content)).toBe('ddl');
});
it('should detect DDL case-insensitively', () => {
const content = `create table users (id int);`;
expect(detectImportMethod(content)).toBe('ddl');
});
});
describe('JSON detection', () => {
it('should detect JSON object', () => {
const content = `{
"tables": [],
"relationships": []
}`;
expect(detectImportMethod(content)).toBe('query');
});
it('should detect JSON array', () => {
const content = `[
{"name": "users"},
{"name": "posts"}
]`;
expect(detectImportMethod(content)).toBe('query');
});
it('should detect minified JSON', () => {
const content = `{"tables":[],"relationships":[]}`;
expect(detectImportMethod(content)).toBe('query');
});
it('should detect JSON with whitespace', () => {
const content = ` {
"data": true
} `;
expect(detectImportMethod(content)).toBe('query');
});
});
describe('edge cases', () => {
it('should return null for empty content', () => {
expect(detectImportMethod('')).toBeNull();
expect(detectImportMethod(' ')).toBeNull();
expect(detectImportMethod('\n\n')).toBeNull();
});
it('should return null for unrecognized content', () => {
const content = `This is just some random text
that doesn't match any pattern`;
expect(detectImportMethod(content)).toBeNull();
});
it('should handle content with special characters', () => {
const content = `Table users {
name varchar // Special chars: áéíóú
}`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should handle malformed JSON gracefully', () => {
const content = `{ "incomplete": `;
expect(detectImportMethod(content)).toBeNull();
});
});
});

View File

@@ -0,0 +1,59 @@
import type { ImportMethod } from './import-method';
export const detectImportMethod = (content: string): ImportMethod | null => {
if (!content || content.trim().length === 0) return null;
const upperContent = content.toUpperCase();
// Check for DBML patterns first (case sensitive)
const dbmlPatterns = [
/^Table\s+\w+\s*{/m,
/^Ref:\s*\w+/m,
/^Enum\s+\w+\s*{/m,
/^TableGroup\s+/m,
/^Note\s+\w+\s*{/m,
/\[pk\]/,
/\[ref:\s*[<>-]/,
];
const hasDBMLPatterns = dbmlPatterns.some((pattern) =>
pattern.test(content)
);
if (hasDBMLPatterns) return 'dbml';
// Common SQL DDL keywords
const ddlKeywords = [
'CREATE TABLE',
'ALTER TABLE',
'DROP TABLE',
'CREATE INDEX',
'CREATE VIEW',
'CREATE PROCEDURE',
'CREATE FUNCTION',
'CREATE SCHEMA',
'CREATE DATABASE',
];
// Check for SQL DDL patterns
const hasDDLKeywords = ddlKeywords.some((keyword) =>
upperContent.includes(keyword)
);
if (hasDDLKeywords) return 'ddl';
// Check if it looks like JSON
try {
// Just check structure, don't need full parse for detection
if (
(content.trim().startsWith('{') && content.trim().endsWith('}')) ||
(content.trim().startsWith('[') && content.trim().endsWith(']'))
) {
return 'query';
}
} catch (error) {
// Not valid JSON, might be partial
console.error('Error detecting content type:', error);
}
// If we can't confidently detect, return null
return null;
};

View File

@@ -0,0 +1 @@
export type ImportMethod = 'query' | 'ddl' | 'dbml';