diff --git a/src/components/code-snippet/code-snippet.tsx b/src/components/code-snippet/code-snippet.tsx index e3389ca7..3d5b997f 100644 --- a/src/components/code-snippet/code-snippet.tsx +++ b/src/components/code-snippet/code-snippet.tsx @@ -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; diff --git a/src/components/code-snippet/languages/dbml-language.ts b/src/components/code-snippet/languages/dbml-language.ts index 71a77a7b..31034a63 100644 --- a/src/components/code-snippet/languages/dbml-language.ts +++ b/src/components/code-snippet/languages/dbml-language.ts @@ -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'], diff --git a/src/dialogs/common/import-database/import-database.tsx b/src/dialogs/common/import-database/import-database.tsx index e818da5f..3382350a 100644 --- a/src/dialogs/common/import-database/import-database.tsx +++ b/src/dialogs/common/import-database/import-database.tsx @@ -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 = ({ @@ -152,9 +113,9 @@ export const ImportDatabase: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ ?.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 = ({ ?.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 = ({
{importMethod === 'query' ? 'Smart Query Output' - : 'SQL Script'} + : importMethod === 'dbml' + ? 'DBML Script' + : 'SQL Script'}
}> } onMount={handleEditorDidMount} + beforeMount={setupDBMLLanguage} theme={ effectiveTheme === 'dark' ? 'dbml-dark' @@ -430,7 +439,6 @@ export const ImportDatabase: React.FC = ({ minimap: { enabled: false }, scrollBeyondLastLine: false, automaticLayout: true, - glyphMargin: false, lineNumbers: 'on', guides: { indentation: false, @@ -455,7 +463,9 @@ export const ImportDatabase: React.FC = ({
- {errorMessage || (importMethod === 'ddl' && sqlValidation) ? ( + {errorMessage || + ((importMethod === 'ddl' || importMethod === 'dbml') && + sqlValidation) ? ( >; - 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 = ({ 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 = ({ className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700" > - + SQL Script + + + + + DBML + )} @@ -167,11 +179,16 @@ export const InstructionsSection: React.FC = ({ showSSMSInfoDialog={showSSMSInfoDialog} setShowSSMSInfoDialog={setShowSSMSInfoDialog} /> - ) : ( + ) : importMethod === 'ddl' ? ( + ) : ( + )} diff --git a/src/dialogs/common/import-database/instructions-section/instructions/dbml-instructions.tsx b/src/dialogs/common/import-database/instructions-section/instructions/dbml-instructions.tsx new file mode 100644 index 00000000..41f168ea --- /dev/null +++ b/src/dialogs/common/import-database/instructions-section/instructions/dbml-instructions.tsx @@ -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 = () => { + return ( + <> +
+
+ Paste your DBML (Database Markup Language) schema definition + here → +
+
+ +
+

Example:

+ users.id] + title varchar + content text +}`} + language={'dbml'} + /> +
+ + ); +}; diff --git a/src/dialogs/common/import-database/sql-validation-status.tsx b/src/dialogs/common/import-database/sql-validation-status.tsx index ed8d64f5..d78b0363 100644 --- a/src/dialogs/common/import-database/sql-validation-status.tsx +++ b/src/dialogs/common/import-database/sql-validation-status.tsx @@ -73,7 +73,7 @@ export const SQLValidationStatus: React.FC = ({ {hasErrors ? (
- +
{validation?.errors .slice(0, 3) @@ -137,7 +137,7 @@ export const SQLValidationStatus: React.FC = ({ {hasWarnings && !hasErrors ? (
- +
diff --git a/src/dialogs/create-diagram-dialog/create-diagram-dialog.tsx b/src/dialogs/create-diagram-dialog/create-diagram-dialog.tsx index b08a8390..ffc92875 100644 --- a/src/dialogs/create-diagram-dialog/create-diagram-dialog.tsx +++ b/src/dialogs/create-diagram-dialog/create-diagram-dialog.tsx @@ -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 = ({ }) => { const { diagramId } = useChartDB(); const { t } = useTranslation(); - const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query'); + const [importMethod, setImportMethod] = useState('query'); const [databaseType, setDatabaseType] = useState( DatabaseType.GENERIC ); @@ -89,6 +94,14 @@ export const CreateDiagramDialog: React.FC = ({ 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 = ({ try { setIsParsingMetadata(true); - if (importMethod === 'ddl') { + if (importMethod === 'ddl' || importMethod === 'dbml') { await importNewDiagram(); } else { // Parse metadata asynchronously to avoid blocking the UI diff --git a/src/dialogs/import-database-dialog/import-database-dialog.tsx b/src/dialogs/import-database-dialog/import-database-dialog.tsx index e7dd28af..d9ecff17 100644 --- a/src/dialogs/import-database-dialog/import-database-dialog.tsx +++ b/src/dialogs/import-database-dialog/import-database-dialog.tsx @@ -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 = ({ dialog, databaseType, }) => { - const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query'); + const [importMethod, setImportMethod] = useState('query'); const { closeImportDatabaseDialog } = useDialog(); const { showAlert } = useAlert(); const { @@ -65,6 +67,10 @@ export const ImportDatabaseDialog: React.FC = ({ sourceDatabaseType: databaseType, targetDatabaseType: databaseType, }); + } else if (importMethod === 'dbml') { + diagram = await importDBMLToDiagram(scriptResult, { + databaseType, + }); } else { const databaseMetadata: DatabaseMetadata = loadDatabaseMetadata(scriptResult); diff --git a/src/dialogs/import-dbml-dialog/import-dbml-dialog.tsx b/src/dialogs/import-dbml-dialog/import-dbml-dialog.tsx index b03b4ad4..1bfd2be8 100644 --- a/src/dialogs/import-dbml-dialog/import-dbml-dialog.tsx +++ b/src/dialogs/import-dbml-dialog/import-dbml-dialog.tsx @@ -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 ( diff --git a/src/lib/data/sql-export/export-sql-script.ts b/src/lib/data/sql-export/export-sql-script.ts index d69f3b4b..4f149ef9 100644 --- a/src/lib/data/sql-export/export-sql-script.ts +++ b/src/lib/data/sql-export/export-sql-script.ts @@ -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 diff --git a/src/lib/data/sql-import/dialect-importers/postgresql/__tests__/test-minimal-type.test.ts b/src/lib/data/sql-import/dialect-importers/postgresql/__tests__/test-minimal-type.test.ts deleted file mode 100644 index 6ac32ca2..00000000 --- a/src/lib/data/sql-import/dialect-importers/postgresql/__tests__/test-minimal-type.test.ts +++ /dev/null @@ -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 - ); - } - }); -}); diff --git a/src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts b/src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts new file mode 100644 index 00000000..fdda08e9 --- /dev/null +++ b/src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts @@ -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); + }); +}); diff --git a/src/lib/dbml/dbml-import/dbml-import.ts b/src/lib/dbml/dbml-import/dbml-import.ts index 969a37ce..28401ea8 100644 --- a/src/lib/dbml/dbml-import/dbml-import.ts +++ b/src/lib/dbml/dbml-import/dbml-import.ts @@ -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, diff --git a/src/lib/dbml/dbml-import/verify-dbml.ts b/src/lib/dbml/dbml-import/verify-dbml.ts new file mode 100644 index 00000000..765b6183 --- /dev/null +++ b/src/lib/dbml/dbml-import/verify-dbml.ts @@ -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, + }; +}; diff --git a/src/lib/import-method/__tests__/detect-import-type.test.ts b/src/lib/import-method/__tests__/detect-import-type.test.ts new file mode 100644 index 00000000..cdca4b26 --- /dev/null +++ b/src/lib/import-method/__tests__/detect-import-type.test.ts @@ -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(); + }); + }); +}); diff --git a/src/lib/import-method/detect-import-method.ts b/src/lib/import-method/detect-import-method.ts new file mode 100644 index 00000000..dcff8be9 --- /dev/null +++ b/src/lib/import-method/detect-import-method.ts @@ -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; +}; diff --git a/src/lib/import-method/import-method.ts b/src/lib/import-method/import-method.ts new file mode 100644 index 00000000..b068d295 --- /dev/null +++ b/src/lib/import-method/import-method.ts @@ -0,0 +1 @@ +export type ImportMethod = 'query' | 'ddl' | 'dbml';