mirror of
https://github.com/chartdb/chartdb.git
synced 2025-11-02 13:03:17 +00:00
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:
@@ -38,7 +38,7 @@ export interface CodeSnippetProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
code: string;
|
code: string;
|
||||||
codeToCopy?: string;
|
codeToCopy?: string;
|
||||||
language?: 'sql' | 'shell';
|
language?: 'sql' | 'shell' | 'dbml';
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
autoScroll?: boolean;
|
autoScroll?: boolean;
|
||||||
isComplete?: boolean;
|
isComplete?: boolean;
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
|
|||||||
base: 'vs-dark',
|
base: 'vs-dark',
|
||||||
inherit: true,
|
inherit: true,
|
||||||
rules: [
|
rules: [
|
||||||
|
{ token: 'comment', foreground: '6A9955' }, // Comments
|
||||||
{ token: 'keyword', foreground: '569CD6' }, // Table, Ref keywords
|
{ token: 'keyword', foreground: '569CD6' }, // Table, Ref keywords
|
||||||
{ token: 'string', foreground: 'CE9178' }, // Strings
|
{ token: 'string', foreground: 'CE9178' }, // Strings
|
||||||
{ token: 'annotation', foreground: '9CDCFE' }, // [annotations]
|
{ token: 'annotation', foreground: '9CDCFE' }, // [annotations]
|
||||||
{ token: 'delimiter', foreground: 'D4D4D4' }, // Braces {}
|
{ token: 'delimiter', foreground: 'D4D4D4' }, // Braces {}
|
||||||
{ token: 'operator', foreground: 'D4D4D4' }, // Operators
|
{ token: 'operator', foreground: 'D4D4D4' }, // Operators
|
||||||
{ token: 'datatype', foreground: '4EC9B0' }, // Data types
|
{ token: 'type', foreground: '4EC9B0' }, // Data types
|
||||||
|
{ token: 'identifier', foreground: '9CDCFE' }, // Field names
|
||||||
],
|
],
|
||||||
colors: {},
|
colors: {},
|
||||||
});
|
});
|
||||||
@@ -23,12 +25,14 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
|
|||||||
base: 'vs',
|
base: 'vs',
|
||||||
inherit: true,
|
inherit: true,
|
||||||
rules: [
|
rules: [
|
||||||
|
{ token: 'comment', foreground: '008000' }, // Comments
|
||||||
{ token: 'keyword', foreground: '0000FF' }, // Table, Ref keywords
|
{ token: 'keyword', foreground: '0000FF' }, // Table, Ref keywords
|
||||||
{ token: 'string', foreground: 'A31515' }, // Strings
|
{ token: 'string', foreground: 'A31515' }, // Strings
|
||||||
{ token: 'annotation', foreground: '001080' }, // [annotations]
|
{ token: 'annotation', foreground: '001080' }, // [annotations]
|
||||||
{ token: 'delimiter', foreground: '000000' }, // Braces {}
|
{ token: 'delimiter', foreground: '000000' }, // Braces {}
|
||||||
{ token: 'operator', foreground: '000000' }, // Operators
|
{ token: 'operator', foreground: '000000' }, // Operators
|
||||||
{ token: 'type', foreground: '267F99' }, // Data types
|
{ token: 'type', foreground: '267F99' }, // Data types
|
||||||
|
{ token: 'identifier', foreground: '001080' }, // Field names
|
||||||
],
|
],
|
||||||
colors: {},
|
colors: {},
|
||||||
});
|
});
|
||||||
@@ -37,23 +41,59 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
|
|||||||
const datatypePattern = dataTypesNames.join('|');
|
const datatypePattern = dataTypesNames.join('|');
|
||||||
|
|
||||||
monaco.languages.setMonarchTokensProvider('dbml', {
|
monaco.languages.setMonarchTokensProvider('dbml', {
|
||||||
keywords: ['Table', 'Ref', 'Indexes', 'Note', 'Enum'],
|
keywords: ['Table', 'Ref', 'Indexes', 'Note', 'Enum', 'enum'],
|
||||||
datatypes: dataTypesNames,
|
datatypes: dataTypesNames,
|
||||||
|
operators: ['>', '<', '-'],
|
||||||
|
|
||||||
tokenizer: {
|
tokenizer: {
|
||||||
root: [
|
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/,
|
/\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',
|
'keyword',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Annotations in brackets
|
||||||
[/\[.*?\]/, 'annotation'],
|
[/\[.*?\]/, 'annotation'],
|
||||||
|
|
||||||
|
// Strings
|
||||||
[/'''/, 'string', '@tripleQuoteString'],
|
[/'''/, 'string', '@tripleQuoteString'],
|
||||||
[/".*?"/, 'string'],
|
[/"([^"\\]|\\.)*$/, 'string.invalid'], // non-terminated string
|
||||||
[/'.*?'/, 'string'],
|
[/'([^'\\]|\\.)*$/, 'string.invalid'], // non-terminated string
|
||||||
|
[/"/, 'string', '@string_double'],
|
||||||
|
[/'/, 'string', '@string_single'],
|
||||||
[/`.*?`/, 'string'],
|
[/`.*?`/, 'string'],
|
||||||
[/[{}]/, 'delimiter'],
|
|
||||||
[/[<>]/, 'operator'],
|
// Delimiters and operators
|
||||||
[new RegExp(`\\b(${datatypePattern})\\b`, 'i'), 'type'], // Added 'i' flag for case-insensitive matching
|
[/[{}()]/, '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: [
|
tripleQuoteString: [
|
||||||
[/[^']+/, 'string'],
|
[/[^']+/, 'string'],
|
||||||
[/'''/, 'string', '@pop'],
|
[/'''/, 'string', '@pop'],
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ import {
|
|||||||
type ValidationResult,
|
type ValidationResult,
|
||||||
} from '@/lib/data/sql-import/sql-validator';
|
} from '@/lib/data/sql-import/sql-validator';
|
||||||
import { SQLValidationStatus } from './sql-validation-status';
|
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 => {
|
const calculateContentSizeMB = (content: string): number => {
|
||||||
return content.length / (1024 * 1024); // Convert to MB
|
return content.length / (1024 * 1024); // Convert to MB
|
||||||
@@ -55,49 +59,6 @@ const calculateIsLargeFile = (content: string): boolean => {
|
|||||||
const errorScriptOutputMessage =
|
const errorScriptOutputMessage =
|
||||||
'Invalid JSON. Please correct it or contact us at support@chartdb.io for help.';
|
'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 {
|
export interface ImportDatabaseProps {
|
||||||
goBack?: () => void;
|
goBack?: () => void;
|
||||||
onImport: () => void;
|
onImport: () => void;
|
||||||
@@ -111,8 +72,8 @@ export interface ImportDatabaseProps {
|
|||||||
>;
|
>;
|
||||||
keepDialogAfterImport?: boolean;
|
keepDialogAfterImport?: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
importMethod: 'query' | 'ddl';
|
importMethod: ImportMethod;
|
||||||
setImportMethod: (method: 'query' | 'ddl') => void;
|
setImportMethod: (method: ImportMethod) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||||
@@ -152,9 +113,9 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
|||||||
setShowCheckJsonButton(false);
|
setShowCheckJsonButton(false);
|
||||||
}, [importMethod, setScriptResult]);
|
}, [importMethod, setScriptResult]);
|
||||||
|
|
||||||
// Check if the ddl is valid
|
// Check if the ddl or dbml is valid
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (importMethod !== 'ddl') {
|
if (importMethod === 'query') {
|
||||||
setSqlValidation(null);
|
setSqlValidation(null);
|
||||||
setShowAutoFixButton(false);
|
setShowAutoFixButton(false);
|
||||||
return;
|
return;
|
||||||
@@ -163,9 +124,48 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
|||||||
if (!scriptResult.trim()) {
|
if (!scriptResult.trim()) {
|
||||||
setSqlValidation(null);
|
setSqlValidation(null);
|
||||||
setShowAutoFixButton(false);
|
setShowAutoFixButton(false);
|
||||||
|
setErrorMessage('');
|
||||||
return;
|
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
|
// First run our validation based on database type
|
||||||
const validation = validateSQL(scriptResult, databaseType);
|
const validation = validateSQL(scriptResult, databaseType);
|
||||||
setSqlValidation(validation);
|
setSqlValidation(validation);
|
||||||
@@ -338,7 +338,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
|||||||
const isLargeFile = calculateIsLargeFile(content);
|
const isLargeFile = calculateIsLargeFile(content);
|
||||||
|
|
||||||
// First, detect content type to determine if we should switch modes
|
// First, detect content type to determine if we should switch modes
|
||||||
const detectedType = detectContentType(content);
|
const detectedType = detectImportMethod(content);
|
||||||
if (detectedType && detectedType !== importMethod) {
|
if (detectedType && detectedType !== importMethod) {
|
||||||
// Switch to the detected mode immediately
|
// Switch to the detected mode immediately
|
||||||
setImportMethod(detectedType);
|
setImportMethod(detectedType);
|
||||||
@@ -352,7 +352,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
|||||||
?.run();
|
?.run();
|
||||||
}, 100);
|
}, 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 {
|
} else {
|
||||||
// Content type didn't change, apply formatting based on current mode
|
// Content type didn't change, apply formatting based on current mode
|
||||||
if (importMethod === 'query' && !isLargeFile) {
|
if (importMethod === 'query' && !isLargeFile) {
|
||||||
@@ -363,7 +363,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
|||||||
?.run();
|
?.run();
|
||||||
}, 100);
|
}, 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">
|
<div className="w-full text-center text-xs text-muted-foreground">
|
||||||
{importMethod === 'query'
|
{importMethod === 'query'
|
||||||
? 'Smart Query Output'
|
? 'Smart Query Output'
|
||||||
: 'SQL Script'}
|
: importMethod === 'dbml'
|
||||||
|
? 'DBML Script'
|
||||||
|
: 'SQL Script'}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<Suspense fallback={<Spinner />}>
|
<Suspense fallback={<Spinner />}>
|
||||||
<Editor
|
<Editor
|
||||||
value={scriptResult}
|
value={scriptResult}
|
||||||
onChange={debouncedHandleInputChange}
|
onChange={debouncedHandleInputChange}
|
||||||
language={importMethod === 'query' ? 'json' : 'sql'}
|
language={
|
||||||
|
importMethod === 'query'
|
||||||
|
? 'json'
|
||||||
|
: importMethod === 'dbml'
|
||||||
|
? 'dbml'
|
||||||
|
: 'sql'
|
||||||
|
}
|
||||||
loading={<Spinner />}
|
loading={<Spinner />}
|
||||||
onMount={handleEditorDidMount}
|
onMount={handleEditorDidMount}
|
||||||
|
beforeMount={setupDBMLLanguage}
|
||||||
theme={
|
theme={
|
||||||
effectiveTheme === 'dark'
|
effectiveTheme === 'dark'
|
||||||
? 'dbml-dark'
|
? 'dbml-dark'
|
||||||
@@ -430,7 +439,6 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
|||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
glyphMargin: false,
|
|
||||||
lineNumbers: 'on',
|
lineNumbers: 'on',
|
||||||
guides: {
|
guides: {
|
||||||
indentation: false,
|
indentation: false,
|
||||||
@@ -455,7 +463,9 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errorMessage || (importMethod === 'ddl' && sqlValidation) ? (
|
{errorMessage ||
|
||||||
|
((importMethod === 'ddl' || importMethod === 'dbml') &&
|
||||||
|
sqlValidation) ? (
|
||||||
<SQLValidationStatus
|
<SQLValidationStatus
|
||||||
validation={sqlValidation}
|
validation={sqlValidation}
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import {
|
|||||||
AvatarImage,
|
AvatarImage,
|
||||||
} from '@/components/avatar/avatar';
|
} from '@/components/avatar/avatar';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Code } from 'lucide-react';
|
import { Code, FileCode } from 'lucide-react';
|
||||||
import { SmartQueryInstructions } from './instructions/smart-query-instructions';
|
import { SmartQueryInstructions } from './instructions/smart-query-instructions';
|
||||||
import { DDLInstructions } from './instructions/ddl-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[] = [
|
const DatabasesWithoutDDLInstructions: DatabaseType[] = [
|
||||||
DatabaseType.CLICKHOUSE,
|
DatabaseType.CLICKHOUSE,
|
||||||
@@ -30,8 +32,8 @@ export interface InstructionsSectionProps {
|
|||||||
setDatabaseEdition: React.Dispatch<
|
setDatabaseEdition: React.Dispatch<
|
||||||
React.SetStateAction<DatabaseEdition | undefined>
|
React.SetStateAction<DatabaseEdition | undefined>
|
||||||
>;
|
>;
|
||||||
importMethod: 'query' | 'ddl';
|
importMethod: ImportMethod;
|
||||||
setImportMethod: (method: 'query' | 'ddl') => void;
|
setImportMethod: (method: ImportMethod) => void;
|
||||||
showSSMSInfoDialog: boolean;
|
showSSMSInfoDialog: boolean;
|
||||||
setShowSSMSInfoDialog: (show: boolean) => void;
|
setShowSSMSInfoDialog: (show: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -125,9 +127,9 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
|
|||||||
className="ml-1 flex-wrap justify-start gap-2"
|
className="ml-1 flex-wrap justify-start gap-2"
|
||||||
value={importMethod}
|
value={importMethod}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
let selectedImportMethod: 'query' | 'ddl' = 'query';
|
let selectedImportMethod: ImportMethod = 'query';
|
||||||
if (value) {
|
if (value) {
|
||||||
selectedImportMethod = value as 'query' | 'ddl';
|
selectedImportMethod = value as ImportMethod;
|
||||||
}
|
}
|
||||||
|
|
||||||
setImportMethod(selectedImportMethod);
|
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"
|
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">
|
<Avatar className="size-4 rounded-none">
|
||||||
<Code size={16} />
|
<FileCode size={16} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
SQL Script
|
SQL Script
|
||||||
</ToggleGroupItem>
|
</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>
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -167,11 +179,16 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
|
|||||||
showSSMSInfoDialog={showSSMSInfoDialog}
|
showSSMSInfoDialog={showSSMSInfoDialog}
|
||||||
setShowSSMSInfoDialog={setShowSSMSInfoDialog}
|
setShowSSMSInfoDialog={setShowSSMSInfoDialog}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : importMethod === 'ddl' ? (
|
||||||
<DDLInstructions
|
<DDLInstructions
|
||||||
databaseType={databaseType}
|
databaseType={databaseType}
|
||||||
databaseEdition={databaseEdition}
|
databaseEdition={databaseEdition}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<DBMLInstructions
|
||||||
|
databaseType={databaseType}
|
||||||
|
databaseEdition={databaseEdition}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -73,7 +73,7 @@ export const SQLValidationStatus: React.FC<SQLValidationStatusProps> = ({
|
|||||||
|
|
||||||
{hasErrors ? (
|
{hasErrors ? (
|
||||||
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950">
|
<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">
|
<div className="space-y-3 p-3 pt-2 text-red-700 dark:text-red-300">
|
||||||
{validation?.errors
|
{validation?.errors
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
@@ -137,7 +137,7 @@ export const SQLValidationStatus: React.FC<SQLValidationStatusProps> = ({
|
|||||||
|
|
||||||
{hasWarnings && !hasErrors ? (
|
{hasWarnings && !hasErrors ? (
|
||||||
<div className="rounded-md border border-sky-200 bg-sky-50 dark:border-sky-800 dark:bg-sky-950">
|
<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="space-y-3 p-3 pt-2 text-sky-700 dark:text-sky-300">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-sky-700 dark:text-sky-300" />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-sky-700 dark:text-sky-300" />
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ import { sqlImportToDiagram } from '@/lib/data/sql-import';
|
|||||||
import type { SelectedTable } from '@/lib/data/import-metadata/filter-metadata';
|
import type { SelectedTable } from '@/lib/data/import-metadata/filter-metadata';
|
||||||
import { filterMetadataByTables } 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 { 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 {}
|
export interface CreateDiagramDialogProps extends BaseDialogProps {}
|
||||||
|
|
||||||
@@ -30,7 +35,7 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { diagramId } = useChartDB();
|
const { diagramId } = useChartDB();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query');
|
const [importMethod, setImportMethod] = useState<ImportMethod>('query');
|
||||||
const [databaseType, setDatabaseType] = useState<DatabaseType>(
|
const [databaseType, setDatabaseType] = useState<DatabaseType>(
|
||||||
DatabaseType.GENERIC
|
DatabaseType.GENERIC
|
||||||
);
|
);
|
||||||
@@ -89,6 +94,14 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
|
|||||||
sourceDatabaseType: databaseType,
|
sourceDatabaseType: databaseType,
|
||||||
targetDatabaseType: 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 {
|
} else {
|
||||||
let metadata: DatabaseMetadata | undefined = databaseMetadata;
|
let metadata: DatabaseMetadata | undefined = databaseMetadata;
|
||||||
|
|
||||||
@@ -171,7 +184,7 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
|
|||||||
try {
|
try {
|
||||||
setIsParsingMetadata(true);
|
setIsParsingMetadata(true);
|
||||||
|
|
||||||
if (importMethod === 'ddl') {
|
if (importMethod === 'ddl' || importMethod === 'dbml') {
|
||||||
await importNewDiagram();
|
await importNewDiagram();
|
||||||
} else {
|
} else {
|
||||||
// Parse metadata asynchronously to avoid blocking the UI
|
// Parse metadata asynchronously to avoid blocking the UI
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { useReactFlow } from '@xyflow/react';
|
|||||||
import type { BaseDialogProps } from '../common/base-dialog-props';
|
import type { BaseDialogProps } from '../common/base-dialog-props';
|
||||||
import { useAlert } from '@/context/alert-context/alert-context';
|
import { useAlert } from '@/context/alert-context/alert-context';
|
||||||
import { sqlImportToDiagram } from '@/lib/data/sql-import';
|
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 {
|
export interface ImportDatabaseDialogProps extends BaseDialogProps {
|
||||||
databaseType: DatabaseType;
|
databaseType: DatabaseType;
|
||||||
@@ -24,7 +26,7 @@ export const ImportDatabaseDialog: React.FC<ImportDatabaseDialogProps> = ({
|
|||||||
dialog,
|
dialog,
|
||||||
databaseType,
|
databaseType,
|
||||||
}) => {
|
}) => {
|
||||||
const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query');
|
const [importMethod, setImportMethod] = useState<ImportMethod>('query');
|
||||||
const { closeImportDatabaseDialog } = useDialog();
|
const { closeImportDatabaseDialog } = useDialog();
|
||||||
const { showAlert } = useAlert();
|
const { showAlert } = useAlert();
|
||||||
const {
|
const {
|
||||||
@@ -65,6 +67,10 @@ export const ImportDatabaseDialog: React.FC<ImportDatabaseDialogProps> = ({
|
|||||||
sourceDatabaseType: databaseType,
|
sourceDatabaseType: databaseType,
|
||||||
targetDatabaseType: databaseType,
|
targetDatabaseType: databaseType,
|
||||||
});
|
});
|
||||||
|
} else if (importMethod === 'dbml') {
|
||||||
|
diagram = await importDBMLToDiagram(scriptResult, {
|
||||||
|
databaseType,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const databaseMetadata: DatabaseMetadata =
|
const databaseMetadata: DatabaseMetadata =
|
||||||
loadDatabaseMetadata(scriptResult);
|
loadDatabaseMetadata(scriptResult);
|
||||||
|
|||||||
@@ -23,24 +23,19 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Editor } from '@/components/code-snippet/code-snippet';
|
import { Editor } from '@/components/code-snippet/code-snippet';
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import {
|
import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import';
|
||||||
importDBMLToDiagram,
|
|
||||||
sanitizeDBML,
|
|
||||||
preprocessDBML,
|
|
||||||
} from '@/lib/dbml/dbml-import/dbml-import';
|
|
||||||
import { useChartDB } from '@/hooks/use-chartdb';
|
import { useChartDB } from '@/hooks/use-chartdb';
|
||||||
import { Parser } from '@dbml/core';
|
|
||||||
import { useCanvas } from '@/hooks/use-canvas';
|
import { useCanvas } from '@/hooks/use-canvas';
|
||||||
import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language';
|
import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language';
|
||||||
import type { DBTable } from '@/lib/domain/db-table';
|
import type { DBTable } from '@/lib/domain/db-table';
|
||||||
import { useToast } from '@/components/toast/use-toast';
|
import { useToast } from '@/components/toast/use-toast';
|
||||||
import { Spinner } from '@/components/spinner/spinner';
|
import { Spinner } from '@/components/spinner/spinner';
|
||||||
import { debounce } from '@/lib/utils';
|
import { debounce } from '@/lib/utils';
|
||||||
import { parseDBMLError } from '@/lib/dbml/dbml-import/dbml-import-error';
|
|
||||||
import {
|
import {
|
||||||
clearErrorHighlight,
|
clearErrorHighlight,
|
||||||
highlightErrorLine,
|
highlightErrorLine,
|
||||||
} from '@/components/code-snippet/dbml/utils';
|
} from '@/components/code-snippet/dbml/utils';
|
||||||
|
import { verifyDBML } from '@/lib/dbml/dbml-import/verify-dbml';
|
||||||
|
|
||||||
export interface ImportDBMLDialogProps extends BaseDialogProps {
|
export interface ImportDBMLDialogProps extends BaseDialogProps {
|
||||||
withCreateEmptyDiagram?: boolean;
|
withCreateEmptyDiagram?: boolean;
|
||||||
@@ -93,6 +88,7 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
|
|||||||
relationships,
|
relationships,
|
||||||
removeTables,
|
removeTables,
|
||||||
removeRelationships,
|
removeRelationships,
|
||||||
|
databaseType,
|
||||||
} = useChartDB();
|
} = useChartDB();
|
||||||
const { reorderTables } = useCanvas();
|
const { reorderTables } = useCanvas();
|
||||||
const [reorder, setReorder] = useState(false);
|
const [reorder, setReorder] = useState(false);
|
||||||
@@ -126,16 +122,15 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
|
|||||||
setErrorMessage(undefined);
|
setErrorMessage(undefined);
|
||||||
clearDecorations();
|
clearDecorations();
|
||||||
|
|
||||||
if (!content.trim()) return;
|
if (!content.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const validateResponse = verifyDBML(content);
|
||||||
const preprocessedContent = preprocessDBML(content);
|
|
||||||
const sanitizedContent = sanitizeDBML(preprocessedContent);
|
if (validateResponse.hasError) {
|
||||||
const parser = new Parser();
|
if (validateResponse.parsedError) {
|
||||||
parser.parse(sanitizedContent, 'dbmlv2');
|
const parsedError = validateResponse.parsedError;
|
||||||
} catch (e) {
|
|
||||||
const parsedError = parseDBMLError(e);
|
|
||||||
if (parsedError) {
|
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
t('import_dbml_dialog.error.description') +
|
t('import_dbml_dialog.error.description') +
|
||||||
` (1 error found - in line ${parsedError.line})`
|
` (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,
|
decorationsCollection.current,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setErrorMessage(
|
setErrorMessage(validateResponse.errorText);
|
||||||
e instanceof Error ? e.message : JSON.stringify(e)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -188,7 +181,9 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
|
|||||||
if (!dbmlContent.trim() || errorMessage) return;
|
if (!dbmlContent.trim() || errorMessage) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const importedDiagram = await importDBMLToDiagram(dbmlContent);
|
const importedDiagram = await importDBMLToDiagram(dbmlContent, {
|
||||||
|
databaseType,
|
||||||
|
});
|
||||||
const tableIdsToRemove = tables
|
const tableIdsToRemove = tables
|
||||||
.filter((table) =>
|
.filter((table) =>
|
||||||
importedDiagram.tables?.some(
|
importedDiagram.tables?.some(
|
||||||
@@ -267,6 +262,7 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
|
|||||||
toast,
|
toast,
|
||||||
setReorder,
|
setReorder,
|
||||||
t,
|
t,
|
||||||
|
databaseType,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
databaseTypesWithCommentSupport,
|
databaseTypesWithCommentSupport,
|
||||||
} from '@/lib/domain/database-type';
|
} from '@/lib/domain/database-type';
|
||||||
import type { DBTable } from '@/lib/domain/db-table';
|
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 { generateCacheKey, getFromCache, setInCache } from './export-sql-cache';
|
||||||
import { exportMSSQL } from './export-per-type/mssql';
|
import { exportMSSQL } from './export-per-type/mssql';
|
||||||
import { exportPostgreSQL } from './export-per-type/postgresql';
|
import { exportPostgreSQL } from './export-per-type/postgresql';
|
||||||
@@ -314,11 +314,26 @@ export const exportBaseSQL = ({
|
|||||||
sqlScript += `(1)`;
|
sqlScript += `(1)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add precision and scale for numeric types
|
// Add precision and scale for numeric types only
|
||||||
if (field.precision && field.scale) {
|
const precisionAndScaleTypes = dataTypeMap[targetDatabaseType]
|
||||||
sqlScript += `(${field.precision}, ${field.scale})`;
|
.filter(
|
||||||
} else if (field.precision) {
|
(t) =>
|
||||||
sqlScript += `(${field.precision})`;
|
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
|
// Handle NOT NULL constraint
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
149
src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts
Normal file
149
src/lib/dbml/dbml-import/__tests__/dbml-integration.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
type DBCustomType,
|
type DBCustomType,
|
||||||
} from '@/lib/domain/db-custom-type';
|
} from '@/lib/domain/db-custom-type';
|
||||||
|
|
||||||
|
export const defaultDBMLDiagramName = 'DBML Import';
|
||||||
|
|
||||||
// Preprocess DBML to handle unsupported features
|
// Preprocess DBML to handle unsupported features
|
||||||
export const preprocessDBML = (content: string): string => {
|
export const preprocessDBML = (content: string): string => {
|
||||||
let processed = content;
|
let processed = content;
|
||||||
@@ -196,7 +198,7 @@ export const importDBMLToDiagram = async (
|
|||||||
if (!dbmlContent.trim()) {
|
if (!dbmlContent.trim()) {
|
||||||
return {
|
return {
|
||||||
id: generateDiagramId(),
|
id: generateDiagramId(),
|
||||||
name: 'DBML Import',
|
name: defaultDBMLDiagramName,
|
||||||
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
|
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
|
||||||
tables: [],
|
tables: [],
|
||||||
relationships: [],
|
relationships: [],
|
||||||
@@ -214,7 +216,7 @@ export const importDBMLToDiagram = async (
|
|||||||
if (!sanitizedContent.trim()) {
|
if (!sanitizedContent.trim()) {
|
||||||
return {
|
return {
|
||||||
id: generateDiagramId(),
|
id: generateDiagramId(),
|
||||||
name: 'DBML Import',
|
name: defaultDBMLDiagramName,
|
||||||
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
|
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
|
||||||
tables: [],
|
tables: [],
|
||||||
relationships: [],
|
relationships: [],
|
||||||
@@ -229,7 +231,7 @@ export const importDBMLToDiagram = async (
|
|||||||
if (!parsedData.schemas || parsedData.schemas.length === 0) {
|
if (!parsedData.schemas || parsedData.schemas.length === 0) {
|
||||||
return {
|
return {
|
||||||
id: generateDiagramId(),
|
id: generateDiagramId(),
|
||||||
name: 'DBML Import',
|
name: defaultDBMLDiagramName,
|
||||||
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
|
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
|
||||||
tables: [],
|
tables: [],
|
||||||
relationships: [],
|
relationships: [],
|
||||||
@@ -734,7 +736,7 @@ export const importDBMLToDiagram = async (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateDiagramId(),
|
id: generateDiagramId(),
|
||||||
name: 'DBML Import',
|
name: defaultDBMLDiagramName,
|
||||||
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
|
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
|
||||||
tables,
|
tables,
|
||||||
relationships,
|
relationships,
|
||||||
|
|||||||
52
src/lib/dbml/dbml-import/verify-dbml.ts
Normal file
52
src/lib/dbml/dbml-import/verify-dbml.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
157
src/lib/import-method/__tests__/detect-import-type.test.ts
Normal file
157
src/lib/import-method/__tests__/detect-import-type.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/lib/import-method/detect-import-method.ts
Normal file
59
src/lib/import-method/detect-import-method.ts
Normal 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;
|
||||||
|
};
|
||||||
1
src/lib/import-method/import-method.ts
Normal file
1
src/lib/import-method/import-method.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type ImportMethod = 'query' | 'ddl' | 'dbml';
|
||||||
Reference in New Issue
Block a user