diff --git a/src/context/dialog-context/dialog-provider.tsx b/src/context/dialog-context/dialog-provider.tsx index 00745bde..4399a1c6 100644 --- a/src/context/dialog-context/dialog-provider.tsx +++ b/src/context/dialog-context/dialog-provider.tsx @@ -101,8 +101,12 @@ export const DialogProvider: React.FC = ({ }); const openImportDatabaseDialogHandler: DialogContext['openImportDatabaseDialog'] = useCallback( - ({ databaseType }) => { - setImportDatabaseDialogParams({ databaseType }); + ({ databaseType, importMethods, initialImportMethod }) => { + setImportDatabaseDialogParams({ + databaseType, + importMethods, + initialImportMethod, + }); setOpenImportDatabaseDialog(true); }, [setOpenImportDatabaseDialog] @@ -144,8 +148,9 @@ export const DialogProvider: React.FC = ({ closeCreateRelationshipDialog: () => setOpenCreateRelationshipDialog(false), openImportDatabaseDialog: openImportDatabaseDialogHandler, - closeImportDatabaseDialog: () => - setOpenImportDatabaseDialog(false), + closeImportDatabaseDialog: () => { + setOpenImportDatabaseDialog(false); + }, openTableSchemaDialog: openTableSchemaDialogHandler, closeTableSchemaDialog: () => setOpenTableSchemaDialog(false), openStarUsDialog: () => setOpenStarUsDialog(true), diff --git a/src/dialogs/common/import-database/import-database.tsx b/src/dialogs/common/import-database/import-database.tsx index 07187a88..db3454cd 100644 --- a/src/dialogs/common/import-database/import-database.tsx +++ b/src/dialogs/common/import-database/import-database.tsx @@ -46,6 +46,8 @@ import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-lang 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'; +import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import'; +import { sqlImportToDiagram } from '@/lib/data/sql-import'; import { clearErrorHighlight, highlightErrorLine, @@ -78,6 +80,7 @@ export interface ImportDatabaseProps { title: string; importMethod: ImportMethod; setImportMethod: (method: ImportMethod) => void; + importMethods?: ImportMethod[]; } export const ImportDatabase: React.FC = ({ @@ -93,6 +96,7 @@ export const ImportDatabase: React.FC = ({ title, importMethod, setImportMethod, + importMethods, }) => { const { effectiveTheme } = useTheme(); const [errorMessage, setErrorMessage] = useState(''); @@ -143,11 +147,31 @@ export const ImportDatabase: React.FC = ({ const validateResponse = verifyDBML(scriptResult, { databaseType }); if (!validateResponse.hasError) { setErrorMessage(''); - setSqlValidation({ - isValid: true, - errors: [], - warnings: [], - }); + + // Try to count tables/relationships for DBML + (async () => { + try { + const diagram = await importDBMLToDiagram( + scriptResult, + { databaseType } + ); + setSqlValidation({ + isValid: true, + errors: [], + warnings: [], + tableCount: diagram.tables?.length ?? 0, + relationshipCount: + diagram.relationships?.length ?? 0, + }); + } catch { + // If parsing fails, just show validation without counts + setSqlValidation({ + isValid: true, + errors: [], + warnings: [], + }); + } + })(); } else { let errorMsg = 'Invalid DBML syntax'; let line: number = 1; @@ -184,10 +208,10 @@ export const ImportDatabase: React.FC = ({ // SQL validation // First run our validation based on database type const validation = validateSQL(scriptResult, databaseType); - setSqlValidation(validation); // If we have auto-fixable errors, show the auto-fix button if (validation.fixedSQL && validation.errors.length > 0) { + setSqlValidation(validation); setShowAutoFixButton(true); // Don't try to parse invalid SQL setErrorMessage('SQL contains syntax errors'); @@ -197,14 +221,33 @@ export const ImportDatabase: React.FC = ({ // Hide auto-fix button if no fixes available setShowAutoFixButton(false); - // Validate the SQL (either original or already fixed) + // Validate the SQL (either original or already fixed) and count tables/relationships parseSQLError({ sqlContent: scriptResult, sourceDatabaseType: databaseType, - }).then((result) => { + }).then(async (result) => { if (result.success) { setErrorMessage(''); + + // Try to parse and count tables/relationships for successful validation + try { + const diagram = await sqlImportToDiagram({ + sqlContent: scriptResult, + sourceDatabaseType: databaseType, + targetDatabaseType: databaseType, + }); + + setSqlValidation({ + ...validation, + tableCount: diagram.tables?.length ?? 0, + relationshipCount: diagram.relationships?.length ?? 0, + }); + } catch { + // If parsing fails, just show validation without counts + setSqlValidation(validation); + } } else if (!result.success && result.error) { + setSqlValidation(validation); setErrorMessage(result.error); } }); @@ -333,6 +376,12 @@ export const ImportDatabase: React.FC = ({ }; }, []); + // Use ref to track current import method to avoid closure issues + const importMethodRef = useRef(importMethod); + useEffect(() => { + importMethodRef.current = importMethod; + }, [importMethod]); + const handleEditorDidMount = useCallback( (editor: editor.IStandaloneCodeEditor) => { editorRef.current = editor; @@ -357,7 +406,8 @@ export const ImportDatabase: React.FC = ({ // First, detect content type to determine if we should switch modes const detectedType = detectImportMethod(content); - if (detectedType && detectedType !== importMethod) { + const currentMethod = importMethodRef.current; + if (detectedType && detectedType !== currentMethod) { // Switch to the detected mode immediately setImportMethod(detectedType); @@ -373,7 +423,7 @@ export const ImportDatabase: React.FC = ({ // 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) { + if (currentMethod === 'query' && !isLargeFile) { // Only format JSON content if not too large setTimeout(() => { editor @@ -387,7 +437,7 @@ export const ImportDatabase: React.FC = ({ pasteDisposableRef.current = disposable; }, - [importMethod, setImportMethod] + [setImportMethod] ); const renderHeader = useCallback(() => { @@ -409,6 +459,7 @@ export const ImportDatabase: React.FC = ({ databaseEdition={databaseEdition} setShowSSMSInfoDialog={setShowSSMSInfoDialog} showSSMSInfoDialog={showSSMSInfoDialog} + importMethods={importMethods} /> ), [ @@ -419,6 +470,7 @@ export const ImportDatabase: React.FC = ({ databaseEdition, setShowSSMSInfoDialog, showSSMSInfoDialog, + importMethods, ] ); @@ -489,6 +541,7 @@ export const ImportDatabase: React.FC = ({ errorMessage={errorMessage} isAutoFixing={isAutoFixing} onErrorClick={handleErrorClick} + importMethod={importMethod} /> ) : null} diff --git a/src/dialogs/common/import-database/instructions-section/instructions-section.tsx b/src/dialogs/common/import-database/instructions-section/instructions-section.tsx index 90c44fc8..e6a8842c 100644 --- a/src/dialogs/common/import-database/instructions-section/instructions-section.tsx +++ b/src/dialogs/common/import-database/instructions-section/instructions-section.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import logo from '@/assets/logo-2.png'; import { ToggleGroup, ToggleGroupItem } from '@/components/toggle/toggle-group'; import { DatabaseType } from '@/lib/domain/database-type'; @@ -36,8 +36,11 @@ export interface InstructionsSectionProps { setImportMethod: (method: ImportMethod) => void; showSSMSInfoDialog: boolean; setShowSSMSInfoDialog: (show: boolean) => void; + importMethods?: ImportMethod[]; } +const defaultImportMethods: ImportMethod[] = ['query', 'ddl', 'dbml']; + export const InstructionsSection: React.FC = ({ databaseType, databaseEdition, @@ -46,12 +49,27 @@ export const InstructionsSection: React.FC = ({ setImportMethod, setShowSSMSInfoDialog, showSSMSInfoDialog, + importMethods = defaultImportMethods, }) => { const { t } = useTranslation(); + const showSmartQuery = useMemo( + () => importMethods.includes('query'), + [importMethods] + ); + const showDDL = useMemo( + () => importMethods.includes('ddl'), + [importMethods] + ); + const showDBML = useMemo( + () => importMethods.includes('dbml'), + [importMethods] + ); + return (
- {databaseTypeToEditionMap[databaseType].length > 0 ? ( + {showSmartQuery && + databaseTypeToEditionMap[databaseType].length > 0 ? (

{t( @@ -134,47 +152,52 @@ export const InstructionsSection: React.FC = ({ setImportMethod(selectedImportMethod); }} > - - - - Query - - Smart Query - - {!DatabasesWithoutDDLInstructions.includes( - databaseType - ) && ( + {showSmartQuery && ( + + + Query + + Smart Query + + )} + {showDDL && + !DatabasesWithoutDDLInstructions.includes( + databaseType + ) && ( + + + + + SQL Script + + )} + {showDBML && ( + - + - SQL Script + DBML )} - - - - - DBML -

Instructions:
- {importMethod === 'query' ? ( + {importMethod === 'query' && showSmartQuery ? ( void; + importMethod?: 'ddl' | 'dbml' | 'query'; } export const SQLValidationStatus: React.FC = ({ @@ -18,6 +19,7 @@ export const SQLValidationStatus: React.FC = ({ errorMessage, isAutoFixing = false, onErrorClick, + importMethod = 'ddl', }) => { const hasErrors = useMemo( () => validation?.errors.length && validation.errors.length > 0, @@ -168,7 +170,46 @@ export const SQLValidationStatus: React.FC = ({
- SQL syntax validated successfully +
+ {importMethod === 'dbml' + ? 'DBML syntax validated successfully' + : 'SQL syntax validated successfully'} +
+ {(validation.tableCount !== undefined || + validation.relationshipCount !== + undefined) && ( +
+ {validation.tableCount !== undefined && + validation.tableCount > 0 && ( + + + {validation.tableCount} + {' '} + table + {validation.tableCount !== 1 + ? 's' + : ''} + + )} + {validation.relationshipCount !== + undefined && + validation.relationshipCount > + 0 && ( + + + { + validation.relationshipCount + } + {' '} + relationship + {validation.relationshipCount !== + 1 + ? 's' + : ''} + + )} +
+ )}
diff --git a/src/dialogs/import-database-dialog/import-database-dialog.tsx b/src/dialogs/import-database-dialog/import-database-dialog.tsx index d9ecff17..6fb79b25 100644 --- a/src/dialogs/import-database-dialog/import-database-dialog.tsx +++ b/src/dialogs/import-database-dialog/import-database-dialog.tsx @@ -10,39 +10,40 @@ import type { Diagram } from '@/lib/domain/diagram'; import { loadFromDatabaseMetadata } from '@/lib/data/import-metadata/import'; import { useChartDB } from '@/hooks/use-chartdb'; import { useRedoUndoStack } from '@/hooks/use-redo-undo-stack'; -import { Trans, useTranslation } from 'react-i18next'; -import { useReactFlow } from '@xyflow/react'; +import { useTranslation } from 'react-i18next'; 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; + importMethods?: ImportMethod[]; + initialImportMethod?: ImportMethod; } +const defaultImportMethods: ImportMethod[] = ['query', 'ddl', 'dbml']; + export const ImportDatabaseDialog: React.FC = ({ dialog, databaseType, + importMethods = defaultImportMethods, + initialImportMethod, }) => { - const [importMethod, setImportMethod] = useState('query'); + const [importMethod, setImportMethod] = useState( + initialImportMethod ?? importMethods[0] + ); const { closeImportDatabaseDialog } = useDialog(); - const { showAlert } = useAlert(); const { - tables, - relationships, - removeTables, - removeRelationships, addTables, addRelationships, diagramName, databaseType: currentDatabaseType, updateDatabaseType, + tables: existingTables, } = useChartDB(); const [scriptResult, setScriptResult] = useState(''); const { resetRedoStack, resetUndoStack } = useRedoUndoStack(); - const { setNodes } = useReactFlow(); const { t } = useTranslation(); const [databaseEdition, setDatabaseEdition] = useState< DatabaseEdition | undefined @@ -56,7 +57,8 @@ export const ImportDatabaseDialog: React.FC = ({ if (!dialog.open) return; setDatabaseEdition(undefined); setScriptResult(''); - }, [dialog.open]); + setImportMethod(initialImportMethod ?? importMethods[0]); + }, [dialog.open, importMethods, initialImportMethod]); const importDatabase = useCallback(async () => { let diagram: Diagram | undefined; @@ -85,247 +87,54 @@ export const ImportDatabaseDialog: React.FC = ({ }); } - const tableIdsToRemove = tables - .filter((table) => - diagram.tables?.some( - (t) => t.name === table.name && t.schema === table.schema - ) - ) - .map((table) => table.id); - - const relationshipIdsToRemove = relationships - .filter((relationship) => { - const sourceTable = tables.find( - (table) => table.id === relationship.sourceTableId - ); - - const targetTable = tables.find( - (table) => table.id === relationship.targetTableId - ); - - if (!sourceTable || !targetTable) return true; // should not happen - - const sourceField = sourceTable.fields.find( - (field) => field.id === relationship.sourceFieldId - ); - - const targetField = targetTable.fields.find( - (field) => field.id === relationship.targetFieldId - ); - - if (!sourceField || !targetField) return true; // should not happen - - const replacementSourceTable = diagram.tables?.find( - (table) => - table.name === sourceTable.name && - table.schema === sourceTable.schema - ); - - const replacementTargetTable = diagram.tables?.find( - (table) => - table.name === targetTable.name && - table.schema === targetTable.schema - ); - - // if the source or target field of the relationship is not in the new table, remove the relationship - if ( - (replacementSourceTable && - !replacementSourceTable.fields.some( - (field) => field.name === sourceField.name - )) || - (replacementTargetTable && - !replacementTargetTable.fields.some( - (field) => field.name === targetField.name - )) - ) { - return true; - } - - return diagram.relationships?.some((r) => { - const sourceNewTable = diagram.tables?.find( - (table) => table.id === r.sourceTableId - ); - - const targetNewTable = diagram.tables?.find( - (table) => table.id === r.targetTableId - ); - - const sourceNewField = sourceNewTable?.fields.find( - (field) => field.id === r.sourceFieldId - ); - - const targetNewField = targetNewTable?.fields.find( - (field) => field.id === r.targetFieldId - ); - - return ( - sourceField.name === sourceNewField?.name && - sourceTable.name === sourceNewTable?.name && - sourceTable.schema === sourceNewTable?.schema && - targetField.name === targetNewField?.name && - targetTable.name === targetNewTable?.name && - targetTable.schema === targetNewTable?.schema - ); - }); - }) - .map((relationship) => relationship.id); - - const newRelationshipsNumber = diagram.relationships?.filter( - (relationship) => { - const newSourceTable = diagram.tables?.find( - (table) => table.id === relationship.sourceTableId - ); - const newTargetTable = diagram.tables?.find( - (table) => table.id === relationship.targetTableId - ); - const newSourceField = newSourceTable?.fields.find( - (field) => field.id === relationship.sourceFieldId - ); - const newTargetField = newTargetTable?.fields.find( - (field) => field.id === relationship.targetFieldId - ); - - return !relationships.some((r) => { - const sourceTable = tables.find( - (table) => table.id === r.sourceTableId - ); - const targetTable = tables.find( - (table) => table.id === r.targetTableId - ); - const sourceField = sourceTable?.fields.find( - (field) => field.id === r.sourceFieldId - ); - const targetField = targetTable?.fields.find( - (field) => field.id === r.targetFieldId - ); - return ( - sourceField?.name === newSourceField?.name && - sourceTable?.name === newSourceTable?.name && - sourceTable?.schema === newSourceTable?.schema && - targetField?.name === newTargetField?.name && - targetTable?.name === newTargetTable?.name && - targetTable?.schema === newTargetTable?.schema - ); - }); - } - ).length; - - const newTablesNumber = diagram.tables?.filter( - (table) => - !tables.some( - (t) => t.name === table.name && t.schema === table.schema - ) - ).length; - - const shouldRemove = new Promise((resolve) => { - if ( - tableIdsToRemove.length === 0 && - relationshipIdsToRemove.length === 0 && - newTablesNumber === 0 && - newRelationshipsNumber === 0 - ) { - resolve(true); - return; - } - - const content = ( - <> -
- {t( - 'import_database_dialog.override_alert.content.alert' - )} -
- {(newTablesNumber ?? 0 > 0) ? ( -
- , - }} - /> -
- ) : null} - {(newRelationshipsNumber ?? 0 > 0) ? ( -
- , - }} - /> -
- ) : null} - {tableIdsToRemove.length > 0 && ( -
- , - }} - /> -
- )} -
- {t( - 'import_database_dialog.override_alert.content.proceed' - )} -
- - ); - - showAlert({ - title: t('import_database_dialog.override_alert.title'), - content, - actionLabel: t('import_database_dialog.override_alert.import'), - closeLabel: t('import_database_dialog.override_alert.cancel'), - onAction: () => resolve(true), - onClose: () => resolve(false), - }); - }); - - if (!(await shouldRemove)) return; - - await Promise.all([ - removeTables(tableIdsToRemove, { updateHistory: false }), - removeRelationships(relationshipIdsToRemove, { - updateHistory: false, - }), - ]); - - await Promise.all([ - addTables(diagram.tables ?? [], { updateHistory: false }), - addRelationships(diagram.relationships ?? [], { - updateHistory: false, - }), - ]); - - if (currentDatabaseType === DatabaseType.GENERIC) { - await updateDatabaseType(databaseType); + // Skip if nothing to import + const newTablesNumber = diagram.tables?.length ?? 0; + const newRelationshipsNumber = diagram.relationships?.length ?? 0; + if (newTablesNumber === 0 && newRelationshipsNumber === 0) { + return; } - setNodes((nodes) => - nodes.map((node) => ({ - ...node, - selected: - diagram.tables?.some((table) => table.id === node.id) ?? - false, - })) - ); - - resetRedoStack(); - resetUndoStack(); - + // Close dialog immediately to prevent re-render blocking closeImportDatabaseDialog(); + + // Calculate position offset for new tables to avoid overlap + let offsetX = 0; + if (existingTables.length > 0) { + // Find the rightmost table + const rightmostTable = existingTables.reduce((max, table) => { + const tableRight = table.x + (table.width ?? 250); + const maxRight = max.x + (max.width ?? 250); + return tableRight > maxRight ? table : max; + }); + // Position new tables 150px to the right of the rightmost table + offsetX = rightmostTable.x + (rightmostTable.width ?? 250) + 150; + } + + // Apply offset to imported tables + const positionedTables = + diagram.tables?.map((table) => ({ + ...table, + x: table.x + offsetX, + })) ?? []; + + // Use queueMicrotask to defer work after dialog closes but before next paint + queueMicrotask(async () => { + // Add tables and relationships + await Promise.all([ + addTables(positionedTables, { updateHistory: false }), + addRelationships(diagram.relationships ?? [], { + updateHistory: false, + }), + ]); + + if (currentDatabaseType === DatabaseType.GENERIC) { + await updateDatabaseType(databaseType); + } + + // Reset undo/redo stacks + resetRedoStack(); + resetUndoStack(); + }); }, [ importMethod, databaseEdition, @@ -333,18 +142,12 @@ export const ImportDatabaseDialog: React.FC = ({ updateDatabaseType, databaseType, scriptResult, - tables, addRelationships, addTables, - closeImportDatabaseDialog, - relationships, - removeRelationships, - removeTables, resetRedoStack, resetUndoStack, - showAlert, - setNodes, - t, + closeImportDatabaseDialog, + existingTables, ]); return ( @@ -371,6 +174,7 @@ export const ImportDatabaseDialog: React.FC = ({ title={t('import_database_dialog.title', { diagramName })} importMethod={importMethod} setImportMethod={setImportMethod} + importMethods={importMethods} /> diff --git a/src/lib/data/sql-import/validators/postgresql-validator.ts b/src/lib/data/sql-import/validators/postgresql-validator.ts index 35c7c588..b0bf3eeb 100644 --- a/src/lib/data/sql-import/validators/postgresql-validator.ts +++ b/src/lib/data/sql-import/validators/postgresql-validator.ts @@ -9,6 +9,7 @@ export interface ValidationResult { warnings: ValidationWarning[]; fixedSQL?: string; tableCount?: number; + relationshipCount?: number; } export interface ValidationError { diff --git a/src/pages/editor-page/canvas/canvas-context-menu.tsx b/src/pages/editor-page/canvas/canvas-context-menu.tsx index bb7c039b..42db6963 100644 --- a/src/pages/editor-page/canvas/canvas-context-menu.tsx +++ b/src/pages/editor-page/canvas/canvas-context-menu.tsx @@ -11,7 +11,7 @@ import { useDialog } from '@/hooks/use-dialog'; import { useReactFlow } from '@xyflow/react'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { Table, Workflow, Group, View, StickyNote } from 'lucide-react'; +import { Table, Workflow, Group, View, StickyNote, Import } from 'lucide-react'; import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter'; import { useLocalConfig } from '@/hooks/use-local-config'; import { useCanvas } from '@/hooks/use-canvas'; @@ -23,7 +23,8 @@ export const CanvasContextMenu: React.FC = ({ const { createTable, readonly, createArea, databaseType, createNote } = useChartDB(); const { schemasDisplayed } = useDiagramFilter(); - const { openCreateRelationshipDialog } = useDialog(); + const { openCreateRelationshipDialog, openImportDatabaseDialog } = + useDialog(); const { screenToFlowPosition } = useReactFlow(); const { t } = useTranslation(); const { showDBViews } = useLocalConfig(); @@ -142,6 +143,16 @@ export const CanvasContextMenu: React.FC = ({ openCreateRelationshipDialog(); }, [openCreateRelationshipDialog]); + const importSqlDbmlHandler = useCallback(() => { + // Defer dialog opening to prevent Radix UI context menu/dialog portal conflicts + queueMicrotask(() => { + openImportDatabaseDialog({ + databaseType, + importMethods: ['ddl', 'dbml'], + }); + }); + }, [openImportDatabaseDialog, databaseType]); + if (!isDesktop) { return <>{children}; } @@ -189,6 +200,14 @@ export const CanvasContextMenu: React.FC = ({ > {t('canvas_context_menu.new_note')} + {' '} + + + Import SQL/DBML + diff --git a/src/pages/editor-page/top-navbar/menu/menu.tsx b/src/pages/editor-page/top-navbar/menu/menu.tsx index f7f9dcd9..28c27d69 100644 --- a/src/pages/editor-page/top-navbar/menu/menu.tsx +++ b/src/pages/editor-page/top-navbar/menu/menu.tsx @@ -188,56 +188,24 @@ export const Menu: React.FC = () => { openImportDatabaseDialog({ - databaseType: DatabaseType.POSTGRESQL, + databaseType, + importMethods: ['ddl', 'dbml'], + initialImportMethod: 'ddl', }) } > - {databaseTypeToLabelMap['postgresql']} + SQL openImportDatabaseDialog({ - databaseType: DatabaseType.MYSQL, + databaseType, + importMethods: ['ddl', 'dbml'], + initialImportMethod: 'dbml', }) } > - {databaseTypeToLabelMap['mysql']} - - - openImportDatabaseDialog({ - databaseType: DatabaseType.SQL_SERVER, - }) - } - > - {databaseTypeToLabelMap['sql_server']} - - - openImportDatabaseDialog({ - databaseType: DatabaseType.MARIADB, - }) - } - > - {databaseTypeToLabelMap['mariadb']} - - - openImportDatabaseDialog({ - databaseType: DatabaseType.SQLITE, - }) - } - > - {databaseTypeToLabelMap['sqlite']} - - - openImportDatabaseDialog({ - databaseType: DatabaseType.ORACLE, - }) - } - > - {databaseTypeToLabelMap['oracle']} + DBML