feat(dbml): Edit Diagram Directly from DBML (#819)

* initial dbml apply

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix
This commit is contained in:
Guy Ben-Aharon
2025-08-04 11:35:34 +03:00
committed by GitHub
parent bc52933b58
commit 1b0390f0b7
22 changed files with 3232 additions and 284 deletions

View File

@@ -31,6 +31,7 @@ export interface CodeSnippetAction {
label: string;
icon: LucideIcon;
onClick: () => void;
className?: string;
}
export interface CodeSnippetProps {
@@ -172,7 +173,10 @@ export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo(
<TooltipTrigger asChild>
<span>
<Button
className="h-fit p-1.5"
className={cn(
'h-fit p-1.5',
action.className
)}
variant="outline"
onClick={action.onClick}
>

View File

@@ -0,0 +1,51 @@
import type { DBMLError } from '@/lib/dbml/dbml-import/dbml-import-error';
import * as monaco from 'monaco-editor';
export const highlightErrorLine = ({
error,
model,
editorDecorationsCollection,
}: {
error: DBMLError;
model?: monaco.editor.ITextModel | null;
editorDecorationsCollection:
| monaco.editor.IEditorDecorationsCollection
| undefined;
}) => {
if (!model) return;
if (!editorDecorationsCollection) return;
const decorations = [
{
range: new monaco.Range(
error.line,
1,
error.line,
model.getLineMaxColumn(error.line)
),
options: {
isWholeLine: true,
className: 'dbml-error-line',
glyphMarginClassName: 'dbml-error-glyph',
hoverMessage: { value: error.message },
overviewRuler: {
color: '#ff0000',
position: monaco.editor.OverviewRulerLane.Right,
darkColor: '#ff0000',
},
},
},
];
editorDecorationsCollection?.set(decorations);
};
export const clearErrorHighlight = (
editorDecorationsCollection:
| monaco.editor.IEditorDecorationsCollection
| undefined
) => {
if (editorDecorationsCollection) {
editorDecorationsCollection.clear();
}
};

View File

@@ -95,6 +95,10 @@ export interface ChartDBContext {
updateDiagramUpdatedAt: () => Promise<void>;
clearDiagramData: () => Promise<void>;
deleteDiagram: () => Promise<void>;
updateDiagramData: (
diagram: Diagram,
options?: { forceUpdateStorage?: boolean }
) => Promise<void>;
// Database type operations
updateDatabaseType: (databaseType: DatabaseType) => Promise<void>;
@@ -317,6 +321,7 @@ export const chartDBContext = createContext<ChartDBContext>({
loadDiagramFromData: emptyFn,
clearDiagramData: emptyFn,
deleteDiagram: emptyFn,
updateDiagramData: emptyFn,
// Database type operations
updateDatabaseType: emptyFn,

View File

@@ -40,7 +40,8 @@ export const ChartDBProvider: React.FC<
React.PropsWithChildren<ChartDBProviderProps>
> = ({ children, diagram, readonly: readonlyProp }) => {
const { hasDiff } = useDiff();
let db = useStorage();
const dbStorage = useStorage();
let db = dbStorage;
const events = useEventEmitter<ChartDBEvent>();
const { setSchemasFilter, schemasFilter } = useLocalConfig();
const { addUndoAction, resetRedoStack, resetUndoStack } =
@@ -1585,6 +1586,16 @@ export const ChartDBProvider: React.FC<
]
);
const updateDiagramData: ChartDBContext['updateDiagramData'] = useCallback(
async (diagram, options) => {
const st = options?.forceUpdateStorage ? dbStorage : db;
await st.deleteDiagram(diagram.id);
await st.addDiagram({ diagram });
loadDiagramFromData(diagram);
},
[db, dbStorage, loadDiagramFromData]
);
const loadDiagram: ChartDBContext['loadDiagram'] = useCallback(
async (diagramId: string) => {
const diagram = await db.getDiagram(diagramId, {
@@ -1787,6 +1798,7 @@ export const ChartDBProvider: React.FC<
events,
readonly,
filterSchemas,
updateDiagramData,
updateDiagramId,
updateDiagramName,
loadDiagram,

View File

@@ -32,14 +32,20 @@ export interface DiffContext {
originalDiagram: Diagram | null;
diffMap: DiffMap;
hasDiff: boolean;
isSummaryOnly: boolean;
calculateDiff: ({
diagram,
newDiagram,
options,
}: {
diagram: Diagram;
newDiagram: Diagram;
options?: {
summaryOnly?: boolean;
};
}) => void;
resetDiff: () => void;
// table diff
checkIfTableHasChange: ({ tableId }: { tableId: string }) => boolean;
@@ -60,6 +66,15 @@ export interface DiffContext {
checkIfNewField: ({ fieldId }: { fieldId: string }) => boolean;
getFieldNewName: ({ fieldId }: { fieldId: string }) => string | null;
getFieldNewType: ({ fieldId }: { fieldId: string }) => DataType | null;
getFieldNewPrimaryKey: ({ fieldId }: { fieldId: string }) => boolean | null;
getFieldNewNullable: ({ fieldId }: { fieldId: string }) => boolean | null;
getFieldNewCharacterMaximumLength: ({
fieldId,
}: {
fieldId: string;
}) => string | null;
getFieldNewScale: ({ fieldId }: { fieldId: string }) => number | null;
getFieldNewPrecision: ({ fieldId }: { fieldId: string }) => number | null;
// relationship diff
checkIfNewRelationship: ({

View File

@@ -32,6 +32,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
const [fieldsChanged, setFieldsChanged] = React.useState<
Map<string, boolean>
>(new Map<string, boolean>());
const [isSummaryOnly, setIsSummaryOnly] = React.useState<boolean>(false);
const events = useEventEmitter<DiffEvent>();
@@ -127,7 +128,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
);
const calculateDiff: DiffContext['calculateDiff'] = useCallback(
({ diagram, newDiagram: newDiagramArg }) => {
({ diagram, newDiagram: newDiagramArg, options }) => {
const {
diffMap: newDiffs,
changedTables: newChangedTables,
@@ -139,6 +140,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
setFieldsChanged(newChangedFields);
setNewDiagram(newDiagramArg);
setOriginalDiagram(diagram);
setIsSummaryOnly(options?.summaryOnly ?? false);
events.emit({
action: 'diff_calculated',
@@ -305,6 +307,117 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
[diffMap]
);
const getFieldNewPrimaryKey = useCallback<
DiffContext['getFieldNewPrimaryKey']
>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'primaryKey',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as boolean;
}
}
return null;
},
[diffMap]
);
const getFieldNewNullable = useCallback<DiffContext['getFieldNewNullable']>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'nullable',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as boolean;
}
}
return null;
},
[diffMap]
);
const getFieldNewCharacterMaximumLength = useCallback<
DiffContext['getFieldNewCharacterMaximumLength']
>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'characterMaximumLength',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as string;
}
}
return null;
},
[diffMap]
);
const getFieldNewScale = useCallback<DiffContext['getFieldNewScale']>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'scale',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as number;
}
}
return null;
},
[diffMap]
);
const getFieldNewPrecision = useCallback<
DiffContext['getFieldNewPrecision']
>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'precision',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as number;
}
}
return null;
},
[diffMap]
);
const checkIfNewRelationship = useCallback<
DiffContext['checkIfNewRelationship']
>(
@@ -339,6 +452,15 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
[diffMap]
);
const resetDiff = useCallback<DiffContext['resetDiff']>(() => {
setDiffMap(new Map<string, ChartDBDiff>());
setTablesChanged(new Map<string, boolean>());
setFieldsChanged(new Map<string, boolean>());
setNewDiagram(null);
setOriginalDiagram(null);
setIsSummaryOnly(false);
}, []);
return (
<diffContext.Provider
value={{
@@ -346,8 +468,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
originalDiagram,
diffMap,
hasDiff: diffMap.size > 0,
isSummaryOnly,
calculateDiff,
resetDiff,
// table diff
getTableNewName,
@@ -362,6 +486,11 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
checkIfNewField,
getFieldNewName,
getFieldNewType,
getFieldNewPrimaryKey,
getFieldNewNullable,
getFieldNewCharacterMaximumLength,
getFieldNewScale,
getFieldNewPrecision,
// relationship diff
checkIfNewRelationship,

View File

@@ -5,7 +5,7 @@ import React, {
Suspense,
useRef,
} from 'react';
import * as monaco from 'monaco-editor';
import type * as monaco from 'monaco-editor';
import { useDialog } from '@/hooks/use-dialog';
import {
Dialog,
@@ -36,45 +36,11 @@ 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';
interface DBMLError {
message: string;
line: number;
column: number;
}
function parseDBMLError(error: unknown): DBMLError | null {
try {
if (typeof error === 'string') {
const parsed = JSON.parse(error);
if (parsed.diags?.[0]) {
const diag = parsed.diags[0];
return {
message: diag.message,
line: diag.location.start.line,
column: diag.location.start.column,
};
}
} else if (error && typeof error === 'object' && 'diags' in error) {
const parsed = error as {
diags: Array<{
message: string;
location: { start: { line: number; column: number } };
}>;
};
if (parsed.diags?.[0]) {
return {
message: parsed.diags[0].message,
line: parsed.diags[0].location.start.line,
column: parsed.diags[0].location.start.column,
};
}
}
} catch (e) {
console.error('Error parsing DBML error:', e);
}
return null;
}
import { parseDBMLError } from '@/lib/dbml/dbml-import/dbml-import-error';
import {
clearErrorHighlight,
highlightErrorLine,
} from '@/components/code-snippet/dbml/utils';
export interface ImportDBMLDialogProps extends BaseDialogProps {
withCreateEmptyDiagram?: boolean;
@@ -150,39 +116,8 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
}
}, [reorder, reorderTables]);
const highlightErrorLine = useCallback((error: DBMLError) => {
if (!editorRef.current) return;
const model = editorRef.current.getModel();
if (!model) return;
const decorations = [
{
range: new monaco.Range(
error.line,
1,
error.line,
model.getLineMaxColumn(error.line)
),
options: {
isWholeLine: true,
className: 'dbml-error-line',
glyphMarginClassName: 'dbml-error-glyph',
hoverMessage: { value: error.message },
overviewRuler: {
color: '#ff0000',
position: monaco.editor.OverviewRulerLane.Right,
darkColor: '#ff0000',
},
},
},
];
decorationsCollection.current?.set(decorations);
}, []);
const clearDecorations = useCallback(() => {
decorationsCollection.current?.clear();
clearErrorHighlight(decorationsCollection.current);
}, []);
const validateDBML = useCallback(
@@ -205,7 +140,12 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
t('import_dbml_dialog.error.description') +
` (1 error found - in line ${parsedError.line})`
);
highlightErrorLine(parsedError);
highlightErrorLine({
error: parsedError,
model: editorRef.current?.getModel(),
editorDecorationsCollection:
decorationsCollection.current,
});
} else {
setErrorMessage(
e instanceof Error ? e.message : JSON.stringify(e)
@@ -213,7 +153,7 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
}
}
},
[clearDecorations, highlightErrorLine, t]
[clearDecorations, t]
);
const debouncedValidateRef = useRef<((value: string) => void) | null>(null);

View File

@@ -155,3 +155,29 @@
background-size: 650%;
}
}
/* Edit button emphasis animation */
@keyframes dbml_edit-button-emphasis {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
background-color: rgba(59, 130, 246, 0);
}
50% {
transform: scale(1.1);
box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
background-color: rgba(59, 130, 246, 0.1);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
background-color: rgba(59, 130, 246, 0);
}
}
.dbml-edit-button-emphasis {
animation: dbml_edit-button-emphasis 0.6s ease-in-out;
animation-iteration-count: 1;
position: relative;
z-index: 10;
}

View File

@@ -227,7 +227,7 @@ describe('DBML Export - SQL Generation Tests', () => {
expect(sql).not.toContain('DEFAULT DEFAULT has default');
// The fields should still be in the table
expect(sql).toContain('is_active boolean');
expect(sql).toContain('stock_count int NOT NULL'); // integer gets simplified to int
expect(sql).toContain('stock_count integer NOT NULL'); // integer gets simplified to int
});
it('should handle valid default values correctly', () => {

View File

@@ -11,23 +11,7 @@ import { exportMySQL } from './export-per-type/mysql';
// Function to simplify verbose data type names
const simplifyDataType = (typeName: string): string => {
const typeMap: Record<string, string> = {
'character varying': 'varchar',
'char varying': 'varchar',
integer: 'int',
int4: 'int',
int8: 'bigint',
serial4: 'serial',
serial8: 'bigserial',
float8: 'double precision',
float4: 'real',
bool: 'boolean',
character: 'char',
'timestamp without time zone': 'timestamp',
'timestamp with time zone': 'timestamptz',
'time without time zone': 'time',
'time with time zone': 'timetz',
};
const typeMap: Record<string, string> = {};
return typeMap[typeName.toLowerCase()] || typeName;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,611 @@
import { defaultSchemas } from '@/lib/data/default-schemas';
import type { Area } from '../../domain/area';
import {
DBCustomTypeKind,
type DBCustomType,
} from '../../domain/db-custom-type';
import type { DBDependency } from '../../domain/db-dependency';
import type { DBField } from '../../domain/db-field';
import type { DBIndex } from '../../domain/db-index';
import type { DBRelationship } from '../../domain/db-relationship';
import type { DBTable } from '../../domain/db-table';
import type { Diagram } from '../../domain/diagram';
type SourceIdToDataMap = Record<
string,
{ schema?: string | null; name: string; color?: string }
>;
type IdMappings = {
tables: Record<string, string>;
fields: Record<string, string>;
};
// Key generation functions remain the same for consistency
const createObjectKey = ({
type,
schema,
otherSchema,
parentName,
otherParentName,
name,
otherName,
}: {
type:
| 'table'
| 'field'
| 'index'
| 'relationship'
| 'customType'
| 'dependency'
| 'area';
schema?: string | null;
otherSchema?: string | null;
parentName?: string | null;
otherParentName?: string | null;
name: string;
otherName?: string | null;
}) =>
`${type}-${schema ? `${schema}.` : ''}${otherSchema ? `${otherSchema}.` : ''}${parentName ? `${parentName}.` : ''}${otherParentName ? `${otherParentName}.` : ''}${name}${otherName ? `.${otherName}` : ''}`;
const createObjectKeyFromTable = (table: DBTable) =>
createObjectKey({
type: 'table',
schema: table.schema,
name: table.name,
});
const createObjectKeyFromField = (table: DBTable, field: DBField) =>
createObjectKey({
type: 'field',
schema: table.schema,
parentName: table.name,
name: field.name,
});
const createObjectKeyFromIndex = (table: DBTable, index: DBIndex) =>
createObjectKey({
type: 'index',
schema: table.schema,
parentName: table.name,
name: index.name,
});
const createObjectKeyFromRelationship = (
relationship: DBRelationship,
sourceIdToNameMap: SourceIdToDataMap
) => {
const sourceTable = sourceIdToNameMap[relationship.sourceTableId];
const targetTable = sourceIdToNameMap[relationship.targetTableId];
const sourceField = sourceIdToNameMap[relationship.sourceFieldId];
const targetField = sourceIdToNameMap[relationship.targetFieldId];
if (!sourceTable || !targetTable || !sourceField || !targetField) {
return null;
}
return createObjectKey({
type: 'relationship',
schema: sourceTable.schema,
otherSchema: targetTable.schema,
parentName: sourceTable.name,
otherParentName: targetTable.name,
name: sourceField.name,
otherName: targetField.name,
});
};
const createObjectKeyFromCustomType = (customType: DBCustomType) =>
createObjectKey({
type: 'customType',
schema: customType.schema,
name: customType.name,
});
const createObjectKeyFromDependency = (
dependency: DBDependency,
sourceIdToNameMap: SourceIdToDataMap
) => {
const dependentTable = sourceIdToNameMap[dependency.dependentTableId];
const table = sourceIdToNameMap[dependency.tableId];
if (!dependentTable || !table) {
return null;
}
return createObjectKey({
type: 'dependency',
schema: dependentTable.schema,
otherSchema: table.schema,
name: dependentTable.name,
otherName: table.name,
});
};
const createObjectKeyFromArea = (area: Area) =>
createObjectKey({
type: 'area',
name: area.name,
});
// Helper function to build source mappings
const buildSourceMappings = (sourceDiagram: Diagram) => {
const objectKeysToIdsMap: Record<string, string> = {};
const sourceIdToDataMap: SourceIdToDataMap = {};
// Map tables and their fields/indexes
sourceDiagram.tables?.forEach((table) => {
const tableKey = createObjectKeyFromTable(table);
objectKeysToIdsMap[tableKey] = table.id;
sourceIdToDataMap[table.id] = {
schema: table.schema,
name: table.name,
color: table.color,
};
table.fields?.forEach((field) => {
const fieldKey = createObjectKeyFromField(table, field);
objectKeysToIdsMap[fieldKey] = field.id;
sourceIdToDataMap[field.id] = {
schema: table.schema,
name: field.name,
};
});
table.indexes?.forEach((index) => {
const indexKey = createObjectKeyFromIndex(table, index);
objectKeysToIdsMap[indexKey] = index.id;
});
});
// Map relationships
sourceDiagram.relationships?.forEach((relationship) => {
const key = createObjectKeyFromRelationship(
relationship,
sourceIdToDataMap
);
if (key) {
objectKeysToIdsMap[key] = relationship.id;
}
});
// Map custom types
sourceDiagram.customTypes?.forEach((customType) => {
const key = createObjectKeyFromCustomType(customType);
objectKeysToIdsMap[key] = customType.id;
});
// Map dependencies
sourceDiagram.dependencies?.forEach((dependency) => {
const key = createObjectKeyFromDependency(
dependency,
sourceIdToDataMap
);
if (key) {
objectKeysToIdsMap[key] = dependency.id;
}
});
// Map areas
sourceDiagram.areas?.forEach((area) => {
const key = createObjectKeyFromArea(area);
objectKeysToIdsMap[key] = area.id;
});
return { objectKeysToIdsMap, sourceIdToDataMap };
};
// Functional helper to update tables and collect ID mappings
const updateTables = ({
targetTables,
sourceTables,
}: {
targetTables: DBTable[] | undefined;
sourceTables: DBTable[] | undefined;
objectKeysToIdsMap: Record<string, string>;
sourceIdToDataMap: SourceIdToDataMap;
defaultDatabaseSchema?: string;
}): { tables: DBTable[]; idMappings: IdMappings } => {
if (!targetTables)
return { tables: [], idMappings: { tables: {}, fields: {} } };
if (!sourceTables)
return { tables: targetTables, idMappings: { tables: {}, fields: {} } };
const idMappings: IdMappings = { tables: {}, fields: {} };
// Create a map of source tables by schema + name
const sourceTablesByKey = new Map<string, DBTable>();
sourceTables.forEach((table) => {
const key = createObjectKeyFromTable(table);
sourceTablesByKey.set(key, table);
});
const updatedTables = targetTables.map((targetTable) => {
// Try to find matching source table by schema + name
const targetKey = createObjectKeyFromTable(targetTable);
let sourceTable = sourceTablesByKey.get(targetKey);
// If no exact match, try matching by name only
if (!sourceTable) {
sourceTable = sourceTables.find(
(srcTable) => srcTable.name === targetTable.name
);
}
if (!sourceTable) {
// No matching source table found - keep target as-is
return targetTable;
}
const sourceId = sourceTable.id;
idMappings.tables[targetTable.id] = sourceId;
// Update fields by matching on name within the table
const sourceFieldsByName = new Map<string, DBField>();
sourceTable.fields?.forEach((field) => {
sourceFieldsByName.set(field.name, field);
});
const updatedFields = targetTable.fields?.map((targetField) => {
const sourceField = sourceFieldsByName.get(targetField.name);
if (sourceField) {
idMappings.fields[targetField.id] = sourceField.id;
// Use source field properties when there's a match
return {
...targetField,
id: sourceField.id,
createdAt: sourceField.createdAt,
};
}
// For new fields not in source, keep target field as-is
return targetField;
});
// Update indexes by matching on name within the table
const sourceIndexesByName = new Map<string, DBIndex>();
sourceTable.indexes?.forEach((index) => {
sourceIndexesByName.set(index.name, index);
});
const updatedIndexes = targetTable.indexes?.map((targetIndex) => {
const sourceIndex = sourceIndexesByName.get(targetIndex.name);
if (sourceIndex) {
return {
...targetIndex,
id: sourceIndex.id,
createdAt: sourceIndex.createdAt,
};
}
return targetIndex;
});
// Build the result table, preserving source structure
const resultTable: DBTable = {
...sourceTable,
fields: updatedFields,
indexes: updatedIndexes,
};
// Update nullable, unique, primaryKey from target fields
if (targetTable.fields) {
resultTable.fields = resultTable.fields?.map((field) => {
const targetField = targetTable.fields?.find(
(f) => f.name === field.name
);
if (targetField) {
return {
...field,
nullable: targetField.nullable,
unique: targetField.unique,
primaryKey: targetField.primaryKey,
type: targetField.type,
};
}
return field;
});
}
return resultTable;
});
return { tables: updatedTables, idMappings };
};
// Functional helper to update custom types
const updateCustomTypes = (
customTypes: DBCustomType[] | undefined,
objectKeysToIdsMap: Record<string, string>
): DBCustomType[] => {
if (!customTypes) return [];
return customTypes.map((customType) => {
const key = createObjectKeyFromCustomType(customType);
const sourceId = objectKeysToIdsMap[key];
if (sourceId) {
return { ...customType, id: sourceId };
}
return customType;
});
};
// Functional helper to update relationships
const updateRelationships = (
targetRelationships: DBRelationship[] | undefined,
sourceRelationships: DBRelationship[] | undefined,
idMappings: IdMappings
): DBRelationship[] => {
// If target has no relationships, return empty array (relationships were removed)
if (!targetRelationships || targetRelationships.length === 0) return [];
// If source has no relationships, we need to add the target relationships with updated IDs
if (!sourceRelationships || sourceRelationships.length === 0) {
return targetRelationships.map((targetRel) => {
// Find the source IDs by reversing the mapping lookup
let sourceTableId = targetRel.sourceTableId;
let targetTableId = targetRel.targetTableId;
let sourceFieldId = targetRel.sourceFieldId;
let targetFieldId = targetRel.targetFieldId;
// Find source table/field IDs from the mappings
for (const [targetId, srcId] of Object.entries(idMappings.tables)) {
if (targetId === targetRel.sourceTableId) {
sourceTableId = srcId;
}
if (targetId === targetRel.targetTableId) {
targetTableId = srcId;
}
}
for (const [targetId, srcId] of Object.entries(idMappings.fields)) {
if (targetId === targetRel.sourceFieldId) {
sourceFieldId = srcId;
}
if (targetId === targetRel.targetFieldId) {
targetFieldId = srcId;
}
}
return {
...targetRel,
sourceTableId,
targetTableId,
sourceFieldId,
targetFieldId,
};
});
}
// Map source relationships that have matches in target
const resultRelationships: DBRelationship[] = [];
const matchedTargetRelIds = new Set<string>();
sourceRelationships.forEach((sourceRel) => {
// Find matching target relationship by checking if the target has a relationship
// between the same tables and fields (using the ID mappings)
const targetRel = targetRelationships.find((tgtRel) => {
const mappedSourceTableId = idMappings.tables[tgtRel.sourceTableId];
const mappedTargetTableId = idMappings.tables[tgtRel.targetTableId];
const mappedSourceFieldId = idMappings.fields[tgtRel.sourceFieldId];
const mappedTargetFieldId = idMappings.fields[tgtRel.targetFieldId];
// Check both directions since relationships can be defined in either direction
const directMatch =
sourceRel.sourceTableId === mappedSourceTableId &&
sourceRel.targetTableId === mappedTargetTableId &&
sourceRel.sourceFieldId === mappedSourceFieldId &&
sourceRel.targetFieldId === mappedTargetFieldId;
const reverseMatch =
sourceRel.sourceTableId === mappedTargetTableId &&
sourceRel.targetTableId === mappedSourceTableId &&
sourceRel.sourceFieldId === mappedTargetFieldId &&
sourceRel.targetFieldId === mappedSourceFieldId;
return directMatch || reverseMatch;
});
if (targetRel) {
matchedTargetRelIds.add(targetRel.id);
// Preserve source relationship but update cardinalities from target
const result: DBRelationship = {
...sourceRel,
sourceCardinality: targetRel.sourceCardinality,
targetCardinality: targetRel.targetCardinality,
};
// Only include schema fields if they exist in the source relationship
if (!sourceRel.sourceSchema) {
delete result.sourceSchema;
}
if (!sourceRel.targetSchema) {
delete result.targetSchema;
}
resultRelationships.push(result);
}
});
// Add any target relationships that weren't matched (new relationships)
targetRelationships.forEach((targetRel) => {
if (!matchedTargetRelIds.has(targetRel.id)) {
// Find the source IDs by reversing the mapping lookup
let sourceTableId = targetRel.sourceTableId;
let targetTableId = targetRel.targetTableId;
let sourceFieldId = targetRel.sourceFieldId;
let targetFieldId = targetRel.targetFieldId;
// Find source table/field IDs from the mappings
for (const [targetId, srcId] of Object.entries(idMappings.tables)) {
if (targetId === targetRel.sourceTableId) {
sourceTableId = srcId;
}
if (targetId === targetRel.targetTableId) {
targetTableId = srcId;
}
}
for (const [targetId, srcId] of Object.entries(idMappings.fields)) {
if (targetId === targetRel.sourceFieldId) {
sourceFieldId = srcId;
}
if (targetId === targetRel.targetFieldId) {
targetFieldId = srcId;
}
}
resultRelationships.push({
...targetRel,
sourceTableId,
targetTableId,
sourceFieldId,
targetFieldId,
});
}
});
return resultRelationships;
};
// Functional helper to update dependencies
const updateDependencies = (
targetDependencies: DBDependency[] | undefined,
sourceDependencies: DBDependency[] | undefined,
idMappings: IdMappings
): DBDependency[] => {
if (!targetDependencies) return [];
if (!sourceDependencies) return targetDependencies;
return targetDependencies.map((targetDep) => {
// Find matching source dependency
const sourceDep = sourceDependencies.find((srcDep) => {
const srcTableId = idMappings.tables[targetDep.tableId];
const srcDependentTableId =
idMappings.tables[targetDep.dependentTableId];
return (
srcDep.tableId === srcTableId &&
srcDep.dependentTableId === srcDependentTableId
);
});
if (sourceDep) {
return {
...targetDep,
id: sourceDep.id,
tableId:
idMappings.tables[targetDep.tableId] || targetDep.tableId,
dependentTableId:
idMappings.tables[targetDep.dependentTableId] ||
targetDep.dependentTableId,
};
}
// If no match found, just update the table references
return {
...targetDep,
tableId: idMappings.tables[targetDep.tableId] || targetDep.tableId,
dependentTableId:
idMappings.tables[targetDep.dependentTableId] ||
targetDep.dependentTableId,
};
});
};
// Functional helper to update index field references
const updateIndexFieldReferences = (
tables: DBTable[] | undefined,
idMappings: IdMappings
): DBTable[] => {
if (!tables) return [];
return tables.map((table) => ({
...table,
indexes: table.indexes?.map((index) => ({
...index,
fieldIds: index.fieldIds.map(
(fieldId) => idMappings.fields[fieldId] || fieldId
),
})),
}));
};
export const applyDBMLChanges = ({
sourceDiagram,
targetDiagram,
}: {
sourceDiagram: Diagram;
targetDiagram: Diagram;
}): Diagram => {
// Step 1: Build mappings from source diagram
const { objectKeysToIdsMap, sourceIdToDataMap } =
buildSourceMappings(sourceDiagram);
// Step 2: Update tables and collect ID mappings
const { tables: updatedTables, idMappings } = updateTables({
targetTables: targetDiagram.tables,
sourceTables: sourceDiagram.tables,
objectKeysToIdsMap,
sourceIdToDataMap,
defaultDatabaseSchema: defaultSchemas[sourceDiagram.databaseType],
});
// Step 3: Update all other entities functionally
const newCustomTypes = updateCustomTypes(
targetDiagram.customTypes,
objectKeysToIdsMap
);
const updatedCustomTypes = [
...(sourceDiagram.customTypes?.filter(
(ct) => ct.kind === DBCustomTypeKind.composite
) ?? []),
...newCustomTypes,
];
const updatedRelationships = updateRelationships(
targetDiagram.relationships,
sourceDiagram.relationships,
idMappings
);
const updatedDependencies = updateDependencies(
targetDiagram.dependencies,
sourceDiagram.dependencies,
idMappings
);
// Step 4: Update index field references
const finalTables = updateIndexFieldReferences(updatedTables, idMappings);
// Sort relationships to match source order
const sortedRelationships = [...updatedRelationships].sort((a, b) => {
// Find source relationships to get their order
const sourceRelA = sourceDiagram.relationships?.find(
(r) => r.id === a.id
);
const sourceRelB = sourceDiagram.relationships?.find(
(r) => r.id === b.id
);
if (!sourceRelA || !sourceRelB) return 0;
const indexA = sourceDiagram.relationships?.indexOf(sourceRelA) ?? 0;
const indexB = sourceDiagram.relationships?.indexOf(sourceRelB) ?? 0;
return indexA - indexB;
});
// Return a new diagram object with tables sorted by order
const result: Diagram = {
...sourceDiagram,
tables: finalTables.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
areas: targetDiagram.areas,
relationships: sortedRelationships,
dependencies: updatedDependencies,
customTypes: updatedCustomTypes,
};
return result;
};

View File

@@ -958,6 +958,105 @@ describe('DBML Export - Issue Fixes', () => {
);
});
it('should export in the right format', () => {
const diagram: Diagram = {
id: 'mqqwkkodrxxd',
name: 'Diagram 9',
createdAt: new Date('2025-07-30T15:44:53.967Z'),
updatedAt: new Date('2025-07-30T16:11:22.554Z'),
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: '8ftpn9qn0o2ddrvhzgdjro3zv',
name: 'table_1',
x: 260,
y: 80,
fields: [
{
id: 'w9wlmimvjaci2krhfb4v9bhy0',
name: 'id',
type: { id: 'bigint', name: 'bigint' },
unique: true,
nullable: false,
primaryKey: true,
createdAt: 1753890297335,
},
],
indexes: [],
color: '#4dee8a',
createdAt: 1753890297335,
isView: false,
order: 0,
parentAreaId: null,
},
{
id: 'wofcygo4u9623oueif9k3v734',
name: 'table_2',
x: -178.62499999999994,
y: -244.375,
fields: [
{
id: '6ca6p6lnss4d2top8pjcfsli7',
name: 'id',
type: { id: 'bigint', name: 'bigint' },
unique: true,
nullable: false,
primaryKey: true,
createdAt: 1753891879081,
},
],
indexes: [],
color: '#4dee8a',
createdAt: 1753891879081,
isView: false,
order: 1,
parentAreaId: null,
},
],
relationships: [
{
id: 'o5ynn1x9nxm5ipuugo690doau',
name: 'table_2_id_fk',
sourceTableId: 'wofcygo4u9623oueif9k3v734',
targetTableId: '8ftpn9qn0o2ddrvhzgdjro3zv',
sourceFieldId: '6ca6p6lnss4d2top8pjcfsli7',
targetFieldId: 'w9wlmimvjaci2krhfb4v9bhy0',
sourceCardinality: 'one',
targetCardinality: 'one',
createdAt: 1753891882554,
},
],
dependencies: [],
areas: [],
customTypes: [],
};
const result = generateDBMLFromDiagram(diagram);
const expectedInlineDBML = `Table "table_1" {
"id" bigint [pk, not null]
}
Table "table_2" {
"id" bigint [pk, not null, ref: < "table_1"."id"]
}
`;
const expectedStandardDBML = `Table "table_1" {
"id" bigint [pk, not null]
}
Table "table_2" {
"id" bigint [pk, not null]
}
Ref "fk_0_table_2_id_fk":"table_1"."id" < "table_2"."id"
`;
expect(result.inlineDbml).toBe(expectedInlineDBML);
expect(result.standardDbml).toBe(expectedStandardDBML);
});
it('should handle tables with multiple relationships correctly', () => {
const diagram: Diagram = {
id: 'test-diagram',
@@ -1173,7 +1272,7 @@ describe('DBML Export - Issue Fixes', () => {
// Check that the entity_id field in user_activities has multiple relationships in inline DBML
// The field should have both references in a single bracket
expect(result.inlineDbml).toContain(
'"entity_id" int [not null, ref: < "posts"."id", ref: < "reviews"."id"]'
'"entity_id" integer [not null, ref: < "posts"."id", ref: < "reviews"."id"]'
);
// Check that standard DBML has separate Ref entries for each relationship

View File

@@ -6,7 +6,6 @@ import type { DBTable } from '@/lib/domain/db-table';
import { type DBField } from '@/lib/domain/db-field';
import type { DBCustomType } from '@/lib/domain/db-custom-type';
import { DBCustomTypeKind } from '@/lib/domain/db-custom-type';
import { defaultSchemas } from '@/lib/data/default-schemas';
// Use DBCustomType for generating Enum DBML
const generateEnumsDBML = (customTypes: DBCustomType[] | undefined): string => {
@@ -251,7 +250,7 @@ const convertToInlineRefs = (dbml: string): string => {
} = {};
// Updated pattern to handle various table name formats including schema.table
const tablePattern =
/Table\s+(?:"([^"]+)"(?:\."([^"]+)")?|(\[?[^\s[]+\]?\.\[?[^\s\]]+\]?)|(\[?[^\s[{]+\]?))\s*{([^}]*)}/g;
/Table\s+(?:"([^"]+)"(?:\."([^"]+)")?|(\[?[^\s[]+\]?\.\[?[^\s\]]+\]?)|(\[?[^\s[{]+\]?))\s*{([^}]*)}/gs;
let tableMatch;
while ((tableMatch = tablePattern.exec(dbml)) !== null) {
@@ -397,18 +396,14 @@ const convertToInlineRefs = (dbml: string): string => {
reconstructedDbml += dbml.substring(lastIndex, tableData.start);
// Preserve the original table definition format but with updated content
const originalTableDef = tableData.fullMatch;
// Ensure the content ends with proper whitespace before the closing brace
let content = tableData.content;
// Check if content ends with a field that has inline refs
if (content.match(/\[.*ref:.*\]\s*$/)) {
// Ensure there's a newline before the closing brace
content = content.trimEnd() + '\n';
// Ensure content ends with a newline before the closing brace
let formattedContent = tableData.content;
if (!formattedContent.endsWith('\n')) {
formattedContent = formattedContent + '\n';
}
const updatedTableDef = originalTableDef.replace(
/{[^}]*}/,
`{${content}}`
/{[^}]*}/s,
`{${formattedContent}}`
);
reconstructedDbml += updatedTableDef;
lastIndex = tableData.end;
@@ -422,7 +417,10 @@ const convertToInlineRefs = (dbml: string): string => {
const finalDbml = finalLines.join('\n').trim();
// Clean up excessive empty lines - replace multiple consecutive empty lines with just one
const cleanedDbml = finalDbml.replace(/\n\s*\n\s*\n/g, '\n\n');
// But ensure there's at least one blank line between tables
const cleanedDbml = finalDbml
.replace(/\n\s*\n\s*\n/g, '\n\n')
.replace(/}\n(?=Table)/g, '}\n\n');
return cleanedDbml;
};
@@ -519,15 +517,15 @@ const fixTableBracketSyntax = (dbml: string): string => {
};
// Restore schema information that may have been stripped by the DBML importer
const restoreTableSchemas = (dbml: string, diagram: Diagram): string => {
if (!diagram.tables) return dbml;
const restoreTableSchemas = (dbml: string, tables: DBTable[]): string => {
if (!tables || tables.length === 0) return dbml;
// Group tables by name to handle duplicates
const tablesByName = new Map<
string,
Array<{ table: (typeof diagram.tables)[0]; index: number }>
Array<{ table: DBTable; index: number }>
>();
diagram.tables.forEach((table, index) => {
tables.forEach((table, index) => {
const existing = tablesByName.get(table.name) || [];
existing.push({ table, index });
tablesByName.set(table.name, existing);
@@ -577,30 +575,20 @@ const restoreTableSchemas = (dbml: string, diagram: Diagram): string => {
}
} else {
// Multiple tables with the same name - need to be more careful
const defaultSchema = defaultSchemas[diagram.databaseType];
// Separate tables by whether they have the default schema or not
const defaultSchemaTable = tablesGroup.find(
({ table }) => table.schema === defaultSchema
);
const nonDefaultSchemaTables = tablesGroup.filter(
({ table }) => table.schema && table.schema !== defaultSchema
);
// Find all table definitions for this name
const escapedTableName = tableName.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&'
);
// First, handle tables that already have schema in DBML
const schemaTablePattern = new RegExp(
`Table\\s+"[^"]+"\\.\\s*"${escapedTableName}"\\s*{`,
'g'
);
result = result.replace(schemaTablePattern, (match) => {
// This table already has a schema, keep it as is
return match;
// Get tables that need schema restoration (those without schema in DBML)
const tablesNeedingSchema = tablesGroup.filter(({ table }) => {
// Check if this table's schema is already in the DBML
const schemaPattern = new RegExp(
`Table\\s+"${table.schema}"\\.\\s*"${escapedTableName}"\\s*{`,
'g'
);
return !result.match(schemaPattern);
});
// Then handle tables without schema in DBML
@@ -611,21 +599,25 @@ const restoreTableSchemas = (dbml: string, diagram: Diagram): string => {
let noSchemaMatchIndex = 0;
result = result.replace(noSchemaTablePattern, (match) => {
// If we have a table with the default schema and this is the first match without schema,
// it should be the default schema table
if (noSchemaMatchIndex === 0 && defaultSchemaTable) {
noSchemaMatchIndex++;
return `Table "${defaultSchema}"."${tableName}" {`;
// We need to match based on the order in the DBML output
// For PostgreSQL DBML, the @dbml/core sorts tables by:
// 1. Tables with schemas (alphabetically)
// 2. Tables without schemas
// Since both our tables have schemas, they should appear in order
// Only process tables that need schema restoration
if (noSchemaMatchIndex >= tablesNeedingSchema.length) {
return match;
}
// Otherwise, try to match with non-default schema tables
const remainingNonDefault =
nonDefaultSchemaTables[
noSchemaMatchIndex - (defaultSchemaTable ? 1 : 0)
];
if (remainingNonDefault) {
noSchemaMatchIndex++;
return `Table "${remainingNonDefault.table.schema}"."${tableName}" {`;
const correspondingTable =
tablesNeedingSchema[noSchemaMatchIndex];
noSchemaMatchIndex++;
if (correspondingTable && correspondingTable.table.schema) {
return `Table "${correspondingTable.table.schema}"."${tableName}" {`;
}
// If the table doesn't have a schema, keep it as is
return match;
});
}
@@ -837,7 +829,7 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
);
// Restore schema information that may have been stripped by DBML importer
standard = restoreTableSchemas(standard, diagram);
standard = restoreTableSchemas(standard, uniqueTables);
// Prepend Enum DBML to the standard output
if (enumsDBML) {
@@ -849,6 +841,14 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
// Clean up excessive empty lines in both outputs
standard = standard.replace(/\n\s*\n\s*\n/g, '\n\n');
inline = inline.replace(/\n\s*\n\s*\n/g, '\n\n');
// Ensure proper formatting with newline at end
if (!standard.endsWith('\n')) {
standard += '\n';
}
if (!inline.endsWith('\n')) {
inline += '\n';
}
} catch (error: unknown) {
console.error(
'Error during DBML generation process:',

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { importDBMLToDiagram } from '../dbml-import';
import { DBCustomTypeKind } from '@/lib/domain/db-custom-type';
describe('DBML Import - Fantasy Examples', () => {
describe('Magical Academy System', () => {
@@ -613,6 +614,228 @@ Note quest_system_note {
});
});
describe('Enum Support', () => {
it('should import enums as customTypes', async () => {
const dbmlWithEnums = `
// Test DBML with various enum definitions
enum job_status {
created [note: 'Waiting to be processed']
running
done
failure
}
// Enum with schema
enum hr.employee_type {
full_time
part_time
contractor
intern
}
// Enum with special characters and spaces
enum grade {
"A+"
"A"
"A-"
"Not Yet Set"
}
Table employees {
id integer [pk]
name varchar(200) [not null]
status job_status
type hr.employee_type
performance_grade grade
created_at timestamp [default: 'now()']
}
Table projects {
id integer [pk]
name varchar(300) [not null]
status job_status [not null]
priority enum // inline enum without values - will be converted to varchar
}`;
const diagram = await importDBMLToDiagram(dbmlWithEnums);
// Verify customTypes are created for enums
expect(diagram.customTypes).toBeDefined();
expect(diagram.customTypes).toHaveLength(3); // job_status, hr.employee_type, grade
// Check job_status enum
const jobStatusEnum = diagram.customTypes?.find(
(ct) => ct.name === 'job_status' && !ct.schema
);
expect(jobStatusEnum).toBeDefined();
expect(jobStatusEnum?.kind).toBe(DBCustomTypeKind.enum);
expect(jobStatusEnum?.values).toEqual([
'created',
'running',
'done',
'failure',
]);
// Check hr.employee_type enum with schema
const employeeTypeEnum = diagram.customTypes?.find(
(ct) => ct.name === 'employee_type' && ct.schema === 'hr'
);
expect(employeeTypeEnum).toBeDefined();
expect(employeeTypeEnum?.kind).toBe(DBCustomTypeKind.enum);
expect(employeeTypeEnum?.values).toEqual([
'full_time',
'part_time',
'contractor',
'intern',
]);
// Check grade enum with quoted values
const gradeEnum = diagram.customTypes?.find(
(ct) => ct.name === 'grade' && !ct.schema
);
expect(gradeEnum).toBeDefined();
expect(gradeEnum?.kind).toBe(DBCustomTypeKind.enum);
expect(gradeEnum?.values).toEqual(['A+', 'A', 'A-', 'Not Yet Set']);
// Verify tables are created
expect(diagram.tables).toHaveLength(2);
// Check that enum fields in tables reference the custom types
const employeesTable = diagram.tables?.find(
(t) => t.name === 'employees'
);
const statusField = employeesTable?.fields.find(
(f) => f.name === 'status'
);
const typeField = employeesTable?.fields.find(
(f) => f.name === 'type'
);
const gradeField = employeesTable?.fields.find(
(f) => f.name === 'performance_grade'
);
// Verify fields have correct types
expect(statusField?.type.id).toBe('job_status');
expect(typeField?.type.id).toBe('employee_type');
expect(gradeField?.type.id).toBe('grade');
// Check inline enum was converted to varchar
const projectsTable = diagram.tables?.find(
(t) => t.name === 'projects'
);
const priorityField = projectsTable?.fields.find(
(f) => f.name === 'priority'
);
expect(priorityField?.type.id).toBe('varchar');
});
it('should handle enum values with notes', async () => {
const dbmlWithEnumNotes = `
enum order_status {
pending [note: 'Order has been placed but not confirmed']
confirmed [note: 'Payment received and order confirmed']
shipped [note: 'Order has been dispatched']
delivered [note: 'Order delivered to customer']
cancelled [note: 'Order cancelled by customer or system']
}
Table orders {
id integer [pk]
status order_status [not null]
}`;
const diagram = await importDBMLToDiagram(dbmlWithEnumNotes);
// Verify enum is created
expect(diagram.customTypes).toHaveLength(1);
const orderStatusEnum = diagram.customTypes?.[0];
expect(orderStatusEnum?.name).toBe('order_status');
expect(orderStatusEnum?.kind).toBe(DBCustomTypeKind.enum);
expect(orderStatusEnum?.values).toEqual([
'pending',
'confirmed',
'shipped',
'delivered',
'cancelled',
]);
});
it('should handle multiple schemas with same enum names', async () => {
const dbmlWithSameEnumNames = `
// Public schema status enum
enum status {
active
inactive
deleted
}
// Admin schema status enum with different values
enum admin.status {
pending_approval
approved
rejected
suspended
}
Table public.users {
id integer [pk]
status status
}
Table admin.users {
id integer [pk]
status admin.status
}`;
const diagram = await importDBMLToDiagram(dbmlWithSameEnumNames);
// Verify both enums are created
expect(diagram.customTypes).toHaveLength(2);
// Check public.status enum
const publicStatusEnum = diagram.customTypes?.find(
(ct) => ct.name === 'status' && !ct.schema
);
expect(publicStatusEnum).toBeDefined();
expect(publicStatusEnum?.values).toEqual([
'active',
'inactive',
'deleted',
]);
// Check admin.status enum
const adminStatusEnum = diagram.customTypes?.find(
(ct) => ct.name === 'status' && ct.schema === 'admin'
);
expect(adminStatusEnum).toBeDefined();
expect(adminStatusEnum?.values).toEqual([
'pending_approval',
'approved',
'rejected',
'suspended',
]);
// Verify fields reference correct enums
const publicUsersTable = diagram.tables?.find(
(t) => t.name === 'users' && t.schema === 'public'
);
const adminUsersTable = diagram.tables?.find(
(t) => t.name === 'users' && t.schema === 'admin'
);
const publicStatusField = publicUsersTable?.fields.find(
(f) => f.name === 'status'
);
const adminStatusField = adminUsersTable?.fields.find(
(f) => f.name === 'status'
);
expect(publicStatusField?.type.id).toBe('status');
expect(adminStatusField?.type.id).toBe('status');
});
});
describe('Edge Cases and Special Features', () => {
it('should handle tables with all DBML features', async () => {
const edgeCaseDBML = `
@@ -793,11 +1016,11 @@ Table "bb"."users" {
expect(bbUsersTable?.fields).toHaveLength(1);
expect(aaUsersTable?.fields[0].name).toBe('id');
expect(aaUsersTable?.fields[0].type.id).toBe('int');
expect(aaUsersTable?.fields[0].type.id).toBe('integer');
expect(aaUsersTable?.fields[0].primaryKey).toBe(true);
expect(bbUsersTable?.fields[0].name).toBe('id');
expect(bbUsersTable?.fields[0].type.id).toBe('int');
expect(bbUsersTable?.fields[0].type.id).toBe('integer');
expect(bbUsersTable?.fields[0].primaryKey).toBe(true);
});

View File

@@ -0,0 +1,40 @@
export interface DBMLError {
message: string;
line: number;
column: number;
}
export function parseDBMLError(error: unknown): DBMLError | null {
try {
if (typeof error === 'string') {
const parsed = JSON.parse(error);
if (parsed.diags?.[0]) {
const diag = parsed.diags[0];
return {
message: diag.message,
line: diag.location.start.line,
column: diag.location.start.column,
};
}
} else if (error && typeof error === 'object' && 'diags' in error) {
const parsed = error as {
diags: Array<{
message: string;
location: { start: { line: number; column: number } };
}>;
};
if (parsed.diags?.[0]) {
return {
message: parsed.diags[0].message,
line: parsed.diags[0].location.start.line,
column: parsed.diags[0].location.start.column,
};
}
}
} catch (e) {
console.error('Error parsing DBML error:', e);
}
return null;
}

View File

@@ -4,10 +4,16 @@ import { generateDiagramId, generateId } from '@/lib/utils';
import type { DBTable } from '@/lib/domain/db-table';
import type { Cardinality, DBRelationship } from '@/lib/domain/db-relationship';
import type { DBField } from '@/lib/domain/db-field';
import type { DataType } from '@/lib/data/data-types/data-types';
import { genericDataTypes } from '@/lib/data/data-types/generic-data-types';
import type { DataTypeData } from '@/lib/data/data-types/data-types';
import { findDataTypeDataById } from '@/lib/data/data-types/data-types';
import { randomColor } from '@/lib/colors';
import { DatabaseType } from '@/lib/domain/database-type';
import type Field from '@dbml/core/types/model_structure/field';
import type { DBIndex } from '@/lib/domain';
import {
DBCustomTypeKind,
type DBCustomType,
} from '@/lib/domain/db-custom-type';
// Preprocess DBML to handle unsupported features
export const preprocessDBML = (content: string): string => {
@@ -19,8 +25,8 @@ export const preprocessDBML = (content: string): string => {
// Remove Note blocks
processed = processed.replace(/Note\s+\w+\s*\{[^}]*\}/gs, '');
// Remove enum definitions (blocks)
processed = processed.replace(/enum\s+\w+\s*\{[^}]*\}/gs, '');
// Don't remove enum definitions - we'll parse them
// processed = processed.replace(/enum\s+\w+\s*\{[^}]*\}/gs, '');
// Handle array types by converting them to text
processed = processed.replace(/(\w+)\[\]/g, 'text');
@@ -77,6 +83,9 @@ interface DBMLField {
pk?: boolean;
not_null?: boolean;
increment?: boolean;
characterMaximumLength?: string | null;
precision?: number | null;
scale?: number | null;
}
interface DBMLIndexColumn {
@@ -110,39 +119,51 @@ interface DBMLRef {
endpoints: [DBMLEndpoint, DBMLEndpoint];
}
const mapDBMLTypeToGenericType = (dbmlType: string): DataType => {
interface DBMLEnum {
name: string;
schema?: string | { name: string };
values: Array<{ name: string; note?: string }>;
note?: string | { value: string } | null;
}
const mapDBMLTypeToDataType = (
dbmlType: string,
options?: { databaseType?: DatabaseType; enums?: DBMLEnum[] }
): DataTypeData => {
const normalizedType = dbmlType.toLowerCase().replace(/\(.*\)/, '');
const matchedType = genericDataTypes.find((t) => t.id === normalizedType);
if (matchedType) return matchedType;
const typeMap: Record<string, string> = {
int: 'int',
integer: 'int',
varchar: 'varchar',
bool: 'boolean',
boolean: 'boolean',
number: 'numeric',
string: 'varchar',
text: 'text',
timestamp: 'timestamp',
datetime: 'timestamp',
float: 'float',
double: 'double',
decimal: 'decimal',
bigint: 'bigint',
smallint: 'smallint',
char: 'char',
};
const mappedType = typeMap[normalizedType];
if (mappedType) {
const foundType = genericDataTypes.find((t) => t.id === mappedType);
if (foundType) return foundType;
// Check if it's an enum type
if (options?.enums) {
const enumDef = options.enums.find((e) => {
// Check both with and without schema prefix
const enumName = e.name.toLowerCase();
const enumFullName = e.schema
? `${e.schema}.${enumName}`
: enumName;
return (
normalizedType === enumName || normalizedType === enumFullName
);
});
if (enumDef) {
// Return enum as custom type reference
return {
id: enumDef.name,
name: enumDef.name,
} satisfies DataTypeData;
}
}
const type = genericDataTypes.find((t) => t.id === 'varchar')!;
const matchedType = findDataTypeDataById(
normalizedType,
options?.databaseType
);
if (matchedType) return matchedType;
return {
id: type.id,
name: type.name,
};
id: normalizedType.split(' ').join('_').toLowerCase(),
name: normalizedType,
} satisfies DataTypeData;
};
const determineCardinality = (
@@ -163,7 +184,10 @@ const determineCardinality = (
};
export const importDBMLToDiagram = async (
dbmlContent: string
dbmlContent: string,
options?: {
databaseType?: DatabaseType;
}
): Promise<Diagram> => {
try {
// Handle empty content
@@ -171,7 +195,7 @@ export const importDBMLToDiagram = async (
return {
id: generateDiagramId(),
name: 'DBML Import',
databaseType: DatabaseType.GENERIC,
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
tables: [],
relationships: [],
createdAt: new Date(),
@@ -189,7 +213,7 @@ export const importDBMLToDiagram = async (
return {
id: generateDiagramId(),
name: 'DBML Import',
databaseType: DatabaseType.GENERIC,
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
tables: [],
relationships: [],
createdAt: new Date(),
@@ -204,7 +228,7 @@ export const importDBMLToDiagram = async (
return {
id: generateDiagramId(),
name: 'DBML Import',
databaseType: DatabaseType.GENERIC,
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
tables: [],
relationships: [],
createdAt: new Date(),
@@ -215,6 +239,55 @@ export const importDBMLToDiagram = async (
// Process all schemas, not just the first one
const allTables: DBMLTable[] = [];
const allRefs: DBMLRef[] = [];
const allEnums: DBMLEnum[] = [];
const getFieldExtraAttributes = (
field: Field,
enums: DBMLEnum[]
): Partial<DBMLField> => {
if (!field.type || !field.type.args) {
return {};
}
const args = field.type.args.split(',') as string[];
const dataType = mapDBMLTypeToDataType(field.type.type_name, {
...options,
enums,
});
if (dataType.fieldAttributes?.hasCharMaxLength) {
const charMaxLength = args?.[0];
return {
characterMaximumLength: charMaxLength,
};
} else if (
dataType.fieldAttributes?.precision &&
dataType.fieldAttributes?.scale
) {
const precisionNum = args?.[0] ? parseInt(args[0]) : undefined;
const scaleNum = args?.[1] ? parseInt(args[1]) : undefined;
const precision = precisionNum
? isNaN(precisionNum)
? undefined
: precisionNum
: undefined;
const scale = scaleNum
? isNaN(scaleNum)
? undefined
: scaleNum
: undefined;
return {
precision,
scale,
};
}
return {};
};
parsedData.schemas.forEach((schema) => {
if (schema.tables) {
@@ -230,17 +303,17 @@ export const importDBMLToDiagram = async (
name: table.name,
schema: schemaName,
note: table.note,
fields: table.fields.map(
(field) =>
({
name: field.name,
type: field.type,
unique: field.unique,
pk: field.pk,
not_null: field.not_null,
increment: field.increment,
}) satisfies DBMLField
),
fields: table.fields.map((field): DBMLField => {
return {
name: field.name,
type: field.type,
unique: field.unique,
pk: field.pk,
not_null: field.not_null,
increment: field.increment,
...getFieldExtraAttributes(field, allEnums),
} satisfies DBMLField;
}),
indexes:
table.indexes?.map((dbmlIndex) => {
let indexColumns: string[];
@@ -314,15 +387,34 @@ export const importDBMLToDiagram = async (
}
});
}
if (schema.enums) {
schema.enums.forEach((enumDef) => {
// Get schema name from enum or use schema's name
const enumSchema =
typeof enumDef.schema === 'string'
? enumDef.schema
: enumDef.schema?.name || schema.name;
allEnums.push({
name: enumDef.name,
schema: enumSchema === 'public' ? '' : enumSchema,
values: enumDef.values || [],
note: enumDef.note,
});
});
}
});
// Extract only the necessary data from the parsed DBML
const extractedData: {
tables: DBMLTable[];
refs: DBMLRef[];
enums: DBMLEnum[];
} = {
tables: allTables,
refs: allRefs,
enums: allEnums,
};
// Convert DBML tables to ChartDB table objects
@@ -332,18 +424,24 @@ export const importDBMLToDiagram = async (
const tableSpacing = 300;
// Create fields first so we have their IDs
const fields = table.fields.map((field) => ({
const fields: DBField[] = table.fields.map((field) => ({
id: generateId(),
name: field.name.replace(/['"]/g, ''),
type: mapDBMLTypeToGenericType(field.type.type_name),
type: mapDBMLTypeToDataType(field.type.type_name, {
...options,
enums: extractedData.enums,
}),
nullable: !field.not_null,
primaryKey: field.pk || false,
unique: field.unique || false,
createdAt: Date.now(),
characterMaximumLength: field.characterMaximumLength,
precision: field.precision,
scale: field.scale,
}));
// Convert DBML indexes to ChartDB indexes
const indexes =
const indexes: DBIndex[] =
table.indexes?.map((dbmlIndex) => {
const fieldIds = dbmlIndex.columns.map((columnName) => {
const field = fields.find((f) => f.name === columnName);
@@ -395,7 +493,7 @@ export const importDBMLToDiagram = async (
isView: false,
createdAt: Date.now(),
comments: tableComment,
};
} as DBTable;
});
// Create relationships using the refs
@@ -449,12 +547,43 @@ export const importDBMLToDiagram = async (
}
);
// Convert DBML enums to custom types
const customTypes: DBCustomType[] = extractedData.enums.map(
(enumDef) => {
// Extract values from enum
const values = enumDef.values
.map((v) => {
// Handle both string values and objects with name property
if (typeof v === 'string') {
return v;
} else if (v && typeof v === 'object' && 'name' in v) {
return v.name.replace(/["']/g, ''); // Remove quotes from values
}
return '';
})
.filter((v) => v !== '');
return {
id: generateId(),
schema:
typeof enumDef.schema === 'string'
? enumDef.schema
: undefined,
name: enumDef.name,
kind: DBCustomTypeKind.enum,
values,
order: 0,
} satisfies DBCustomType;
}
);
return {
id: generateDiagramId(),
name: 'DBML Import',
databaseType: DatabaseType.GENERIC,
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
tables,
relationships,
customTypes,
createdAt: new Date(),
updatedAt: new Date(),
};

View File

@@ -329,6 +329,27 @@ function compareFieldProperties({
changedAttributes.push('comments');
}
if (
(newField.characterMaximumLength || oldField.characterMaximumLength) &&
oldField.characterMaximumLength !== newField.characterMaximumLength
) {
changedAttributes.push('characterMaximumLength');
}
if (
(newField.scale || oldField.scale) &&
oldField.scale !== newField.scale
) {
changedAttributes.push('scale');
}
if (
(newField.precision || oldField.precision) &&
oldField.precision !== newField.precision
) {
changedAttributes.push('precision');
}
if (changedAttributes.length > 0) {
for (const attribute of changedAttributes) {
diffMap.set(

View File

@@ -12,7 +12,10 @@ export type FieldDiffAttribute =
| 'primaryKey'
| 'unique'
| 'nullable'
| 'comments';
| 'comments'
| 'characterMaximumLength'
| 'precision'
| 'scale';
export const fieldDiffAttributeSchema: z.ZodType<FieldDiffAttribute> = z.union([
z.literal('name'),
@@ -61,8 +64,8 @@ export interface FieldDiffChanged {
fieldId: string;
tableId: string;
attribute: FieldDiffAttribute;
oldValue: string | boolean | DataType;
newValue: string | boolean | DataType;
oldValue: string | boolean | DataType | number;
newValue: string | boolean | DataType | number;
}
export const fieldDiffChangedSchema: z.ZodType<FieldDiffChanged> = z.object({

View File

@@ -152,7 +152,13 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
checkIfNewField,
getFieldNewName,
getFieldNewType,
getFieldNewNullable,
getFieldNewPrimaryKey,
getFieldNewCharacterMaximumLength,
getFieldNewPrecision,
getFieldNewScale,
checkIfFieldHasChange,
isSummaryOnly,
} = useDiff();
const [diffState, setDiffState] = useState<{
@@ -160,12 +166,22 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
isDiffNewField: boolean;
fieldDiffChangedName: string | null;
fieldDiffChangedType: DBField['type'] | null;
fieldDiffChangedNullable: boolean | null;
fieldDiffChangedCharacterMaximumLength: string | null;
fieldDiffChangedScale: number | null;
fieldDiffChangedPrecision: number | null;
fieldDiffChangedPrimaryKey: boolean | null;
isDiffFieldChanged: boolean;
}>({
isDiffFieldRemoved: false,
isDiffNewField: false,
fieldDiffChangedName: null,
fieldDiffChangedType: null,
fieldDiffChangedNullable: null,
fieldDiffChangedCharacterMaximumLength: null,
fieldDiffChangedScale: null,
fieldDiffChangedPrecision: null,
fieldDiffChangedPrimaryKey: null,
isDiffFieldChanged: false,
});
@@ -183,6 +199,22 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
fieldDiffChangedType: getFieldNewType({
fieldId: field.id,
}),
fieldDiffChangedNullable: getFieldNewNullable({
fieldId: field.id,
}),
fieldDiffChangedPrimaryKey: getFieldNewPrimaryKey({
fieldId: field.id,
}),
fieldDiffChangedCharacterMaximumLength:
getFieldNewCharacterMaximumLength({
fieldId: field.id,
}),
fieldDiffChangedScale: getFieldNewScale({
fieldId: field.id,
}),
fieldDiffChangedPrecision: getFieldNewPrecision({
fieldId: field.id,
}),
isDiffFieldChanged: checkIfFieldHasChange({
fieldId: field.id,
tableId: tableNodeId,
@@ -195,7 +227,12 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
checkIfNewField,
getFieldNewName,
getFieldNewType,
getFieldNewPrimaryKey,
getFieldNewNullable,
checkIfFieldHasChange,
getFieldNewCharacterMaximumLength,
getFieldNewPrecision,
getFieldNewScale,
field.id,
tableNodeId,
]);
@@ -206,6 +243,11 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
fieldDiffChangedName,
fieldDiffChangedType,
isDiffFieldChanged,
fieldDiffChangedNullable,
fieldDiffChangedPrimaryKey,
fieldDiffChangedCharacterMaximumLength,
fieldDiffChangedScale,
fieldDiffChangedPrecision,
} = diffState;
const enterEditMode = useCallback((e: React.MouseEvent) => {
@@ -233,6 +275,7 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
'z-0 max-h-0 overflow-hidden opacity-0': !visible,
'bg-sky-200 dark:bg-sky-800 hover:bg-sky-100 dark:hover:bg-sky-900 border-sky-300 dark:border-sky-700':
isDiffFieldChanged &&
!isSummaryOnly &&
!isDiffFieldRemoved &&
!isDiffNewField,
'bg-red-200 dark:bg-red-800 hover:bg-red-100 dark:hover:bg-red-900 border-red-300 dark:border-red-700':
@@ -297,7 +340,7 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
<SquareMinus className="size-3.5 text-red-800 dark:text-red-200" />
) : isDiffNewField ? (
<SquarePlus className="size-3.5 text-green-800 dark:text-green-200" />
) : isDiffFieldChanged ? (
) : isDiffFieldChanged && !isSummaryOnly ? (
<SquareDot className="size-3.5 shrink-0 text-sky-800 dark:text-sky-200" />
) : null}
{editMode && !readonly ? (
@@ -330,6 +373,7 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
isDiffNewField,
'text-sky-800 font-normal dark:text-sky-200':
isDiffFieldChanged &&
!isSummaryOnly &&
!isDiffFieldRemoved &&
!isDiffNewField,
})}
@@ -359,7 +403,9 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
</div>
{editMode ? null : (
<div className="ml-2 flex shrink-0 items-center justify-end gap-1.5">
{field.primaryKey ? (
{(field.primaryKey &&
fieldDiffChangedPrimaryKey === null) ||
fieldDiffChangedPrimaryKey ? (
<div
className={cn(
'text-muted-foreground',
@@ -371,6 +417,7 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
? 'text-green-800 dark:text-green-200'
: '',
isDiffFieldChanged &&
!isSummaryOnly &&
!isDiffFieldRemoved &&
!isDiffNewField
? 'text-sky-800 dark:text-sky-200'
@@ -394,6 +441,7 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
: '',
isDiffFieldChanged &&
!isDiffFieldRemoved &&
!isSummaryOnly &&
!isDiffNewField
? 'text-sky-800 dark:text-sky-200'
: ''
@@ -412,9 +460,36 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
}
</>
) : (
`${field.type.name.split(' ')[0]}${showFieldAttributes ? generateDBFieldSuffix(field) : ''}`
`${field.type.name.split(' ')[0]}${
showFieldAttributes
? generateDBFieldSuffix({
...field,
...{
precision:
fieldDiffChangedPrecision ??
field.precision,
scale:
fieldDiffChangedScale ??
field.scale,
characterMaximumLength:
fieldDiffChangedCharacterMaximumLength ??
field.characterMaximumLength,
},
})
: ''
}`
)}
{fieldDiffChangedNullable !== null ? (
fieldDiffChangedNullable ? (
<span className="font-semibold">?</span>
) : (
<span className="line-through">?</span>
)
) : field.nullable ? (
'?'
) : (
''
)}
{field.nullable ? '?' : ''}
</span>
</div>
{readonly ? null : (

View File

@@ -86,6 +86,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
checkIfTableHasChange,
checkIfNewTable,
checkIfTableRemoved,
isSummaryOnly,
} = useDiff();
const fields = useMemo(() => table.fields, [table.fields]);
@@ -312,7 +313,10 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
hasHighlightedCustomType
? 'ring-2 ring-offset-slate-50 dark:ring-offset-slate-900 ring-yellow-500 ring-offset-2 animate-scale'
: '',
isDiffTableChanged && !isDiffNewTable && !isDiffTableRemoved
isDiffTableChanged &&
!isSummaryOnly &&
!isDiffNewTable &&
!isDiffTableRemoved
? 'outline outline-[3px] outline-sky-500 dark:outline-sky-900 outline-offset-[5px]'
: '',
isDiffNewTable
@@ -327,7 +331,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
isOverlapping,
highlightOverlappingTables,
hasHighlightedCustomType,
isSummaryOnly,
isDiffTableChanged,
isDiffNewTable,
isDiffTableRemoved,
@@ -364,7 +368,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
? 'new'
: isDiffTableRemoved
? 'removed'
: isDiffTableChanged
: isDiffTableChanged && !isSummaryOnly
? 'changed'
: 'none'
}
@@ -397,7 +401,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
Table Removed
</TooltipContent>
</Tooltip>
) : isDiffTableChanged ? (
) : isDiffTableChanged && !isSummaryOnly ? (
<Tooltip>
<TooltipTrigger asChild>
<SquareDot
@@ -433,7 +437,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-red-200 px-2 py-0.5 text-sm font-normal text-red-900 dark:bg-red-800 dark:text-red-200">
{table.name}
</Label>
) : isDiffTableChanged ? (
) : isDiffTableChanged && !isSummaryOnly ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
{table.name}
</Label>

View File

@@ -1,4 +1,10 @@
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import React, {
useMemo,
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import type { DBTable } from '@/lib/domain/db-table';
import { useChartDB } from '@/hooks/use-chartdb';
import { useTheme } from '@/hooks/use-theme';
@@ -7,8 +13,28 @@ import type { EffectiveTheme } from '@/context/theme-context/theme-context';
import type { Diagram } from '@/lib/domain/diagram';
import { useToast } from '@/components/toast/use-toast';
import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language';
import { ArrowLeftRight } from 'lucide-react';
import {
AlertCircle,
ArrowLeftRight,
Check,
Pencil,
PencilOff,
Undo2,
X,
} from 'lucide-react';
import { generateDBMLFromDiagram } from '@/lib/dbml/dbml-export/dbml-export';
import { useDiff } from '@/context/diff-context/use-diff';
import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import';
import { applyDBMLChanges } from '@/lib/dbml/apply-dbml/apply-dbml';
import { useDebounce } from '@/hooks/use-debounce';
import { parseDBMLError } from '@/lib/dbml/dbml-import/dbml-import-error';
import {
clearErrorHighlight,
highlightErrorLine,
} from '@/components/code-snippet/dbml/utils';
import type * as monaco from 'monaco-editor';
import { useTranslation } from 'react-i18next';
import { useFullScreenLoader } from '@/hooks/use-full-screen-spinner';
export interface TableDBMLProps {
filteredTables: DBTable[];
@@ -18,62 +44,53 @@ const getEditorTheme = (theme: EffectiveTheme) => {
return theme === 'dark' ? 'dbml-dark' : 'dbml-light';
};
export const TableDBML: React.FC<TableDBMLProps> = ({ filteredTables }) => {
const { currentDiagram } = useChartDB();
export const TableDBML: React.FC<TableDBMLProps> = () => {
const { currentDiagram, updateDiagramData, databaseType } = useChartDB();
const { effectiveTheme } = useTheme();
const { toast } = useToast();
const [dbmlFormat, setDbmlFormat] = useState<'inline' | 'standard'>(
'inline'
);
const [isLoading, setIsLoading] = useState(true);
const [standardDbml, setStandardDbml] = useState('');
const [inlineDbml, setInlineDbml] = useState('');
const isMountedRef = useRef(true);
const [isEditButtonEmphasized, setIsEditButtonEmphasized] = useState(false);
// --- Effect for handling empty field name warnings ---
useEffect(() => {
let foundInvalidFields = false;
const invalidTableNames = new Set<string>();
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();
const decorationsCollection =
useRef<monaco.editor.IEditorDecorationsCollection>();
filteredTables.forEach((table) => {
table.fields.forEach((field) => {
if (field.name === '') {
foundInvalidFields = true;
invalidTableNames.add(table.name);
const handleEditorDidMount = useCallback(
(editor: monaco.editor.IStandaloneCodeEditor) => {
editorRef.current = editor;
decorationsCollection.current =
editor.createDecorationsCollection();
if (readOnlyDisposableRef.current) {
readOnlyDisposableRef.current.dispose();
}
const readOnlyDisposable = editor.onDidAttemptReadOnlyEdit(() => {
if (emphasisTimeoutRef.current) {
clearTimeout(emphasisTimeoutRef.current);
}
setIsEditButtonEmphasized(false);
requestAnimationFrame(() => {
setIsEditButtonEmphasized(true);
emphasisTimeoutRef.current = setTimeout(() => {
setIsEditButtonEmphasized(false);
}, 600);
});
});
});
if (foundInvalidFields) {
const tableNamesString = Array.from(invalidTableNames).join(', ');
toast({
title: 'Warning',
description: `Some fields had empty names in tables: [${tableNamesString}] and were excluded from the DBML export.`,
variant: 'default',
});
}
}, [filteredTables, toast]); // Depend on filteredTables and toast
// Generate both standard and inline DBML formats
const { standardDbml, inlineDbml } = useMemo(() => {
// Create a filtered diagram with only the selected tables
const filteredDiagram: Diagram = {
...currentDiagram,
tables: filteredTables,
};
const result = generateDBMLFromDiagram(filteredDiagram);
// Handle errors
if (result.error) {
toast({
title: 'DBML Export Error',
description: `Could not generate DBML: ${result.error.substring(0, 100)}${result.error.length > 100 ? '...' : ''}`,
variant: 'destructive',
});
}
return {
standardDbml: result.standardDbml,
inlineDbml: result.inlineDbml,
};
}, [currentDiagram, filteredTables, toast]);
readOnlyDisposableRef.current = readOnlyDisposable;
},
[]
);
// Determine which DBML string to display
const dbmlToDisplay = useMemo(
@@ -86,30 +103,339 @@ export const TableDBML: React.FC<TableDBMLProps> = ({ filteredTables }) => {
setDbmlFormat((prev) => (prev === 'inline' ? 'standard' : 'inline'));
}, []);
const [isEditMode, setIsEditMode] = useState(false);
const [editedDbml, setEditedDbml] = useState<string>('');
const lastDBMLChange = useRef(editedDbml);
const { calculateDiff, originalDiagram, resetDiff, hasDiff, newDiagram } =
useDiff();
const { loadDiagramFromData } = useChartDB();
const [errorMessage, setErrorMessage] = useState<string>();
const [warningMessage, setWarningMessage] = useState<string>();
const { t } = useTranslation();
const { hideLoader, showLoader } = useFullScreenLoader();
const emphasisTimeoutRef = useRef<NodeJS.Timeout>();
const readOnlyDisposableRef = useRef<monaco.IDisposable>();
// --- Check for empty field name warnings only on mount ---
useEffect(() => {
// Only check when not in edit mode
if (isEditMode) return;
let foundInvalidFields = false;
const invalidTableNames = new Set<string>();
currentDiagram.tables?.forEach((table) => {
table.fields.forEach((field) => {
if (field.name === '') {
foundInvalidFields = true;
invalidTableNames.add(table.name);
}
});
});
if (foundInvalidFields) {
const tableNamesString = Array.from(invalidTableNames).join(', ');
setWarningMessage(
`Some fields had empty names in tables: [${tableNamesString}] and were excluded from the DBML export.`
);
}
}, [currentDiagram.tables, t, isEditMode]);
useEffect(() => {
if (isEditMode) {
setIsLoading(false);
return;
}
setErrorMessage(undefined);
clearErrorHighlight(decorationsCollection.current);
const generateDBML = async () => {
setIsLoading(true);
const result = generateDBMLFromDiagram(currentDiagram);
// Handle errors
if (result.error) {
toast({
title: 'DBML Export Error',
description: `Could not generate DBML: ${result.error.substring(0, 100)}${result.error.length > 100 ? '...' : ''}`,
variant: 'destructive',
});
}
setStandardDbml(result.standardDbml);
setInlineDbml(result.inlineDbml);
setIsLoading(false);
};
setTimeout(() => generateDBML(), 0);
}, [currentDiagram, toast, isEditMode]);
// Update editedDbml when dbmlToDisplay changes
useEffect(() => {
if (!isLoading && dbmlToDisplay && !isEditMode) {
setEditedDbml(dbmlToDisplay);
lastDBMLChange.current = dbmlToDisplay;
}
}, [dbmlToDisplay, isLoading, isEditMode]);
// Create the showDiff function
const showDiff = useCallback(
async (dbmlContent: string) => {
clearErrorHighlight(decorationsCollection.current);
setErrorMessage(undefined);
try {
const diagramFromDBML: Diagram = await importDBMLToDiagram(
dbmlContent,
{ databaseType }
);
const sourceDiagram: Diagram =
originalDiagram ?? currentDiagram;
const targetDiagram: Diagram = {
...sourceDiagram,
tables: diagramFromDBML.tables,
relationships: diagramFromDBML.relationships,
customTypes: diagramFromDBML.customTypes,
};
const newDiagram = applyDBMLChanges({
sourceDiagram,
targetDiagram,
});
if (originalDiagram) {
resetDiff();
loadDiagramFromData(originalDiagram);
}
calculateDiff({
diagram: sourceDiagram,
newDiagram,
options: { summaryOnly: true },
});
} catch (error) {
const dbmlError = parseDBMLError(error);
if (dbmlError) {
highlightErrorLine({
error: dbmlError,
model: editorRef.current?.getModel(),
editorDecorationsCollection:
decorationsCollection.current,
});
setErrorMessage(
t('import_dbml_dialog.error.description') +
` (1 error found - in line ${dbmlError.line})`
);
}
}
},
[
t,
originalDiagram,
currentDiagram,
resetDiff,
loadDiagramFromData,
calculateDiff,
databaseType,
]
);
const debouncedShowDiff = useDebounce(showDiff, 1000);
useEffect(() => {
if (!isEditMode || !editedDbml) {
return;
}
// Only calculate diff if the DBML has changed
if (editedDbml === lastDBMLChange.current) {
return;
}
lastDBMLChange.current = editedDbml;
debouncedShowDiff(editedDbml);
}, [editedDbml, isEditMode, debouncedShowDiff]);
const acceptChanges = useCallback(async () => {
if (!editedDbml) return;
if (!newDiagram) return;
showLoader();
await updateDiagramData(newDiagram, { forceUpdateStorage: true });
resetDiff();
setEditedDbml(editedDbml);
setIsEditMode(false);
lastDBMLChange.current = editedDbml;
hideLoader();
}, [
editedDbml,
updateDiagramData,
newDiagram,
resetDiff,
showLoader,
hideLoader,
]);
const undoChanges = useCallback(() => {
if (!editedDbml) return;
if (!originalDiagram) return;
loadDiagramFromData(originalDiagram);
setIsEditMode(false);
resetDiff();
setEditedDbml(dbmlToDisplay);
lastDBMLChange.current = dbmlToDisplay;
}, [
editedDbml,
loadDiagramFromData,
originalDiagram,
resetDiff,
dbmlToDisplay,
]);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (emphasisTimeoutRef.current) {
clearTimeout(emphasisTimeoutRef.current);
}
if (readOnlyDisposableRef.current) {
readOnlyDisposableRef.current.dispose();
readOnlyDisposableRef.current = undefined;
}
};
}, []);
useEffect(() => {
const currentUndoChanges = undoChanges;
return () => {
setTimeout(() => {
if (!isMountedRef.current) {
currentUndoChanges();
}
}, 0);
};
}, [undoChanges]);
return (
<CodeSnippet
code={dbmlToDisplay}
actionsTooltipSide="right"
className="my-0.5"
actions={[
{
label: `Show ${dbmlFormat === 'inline' ? 'Standard' : 'Inline'} Refs`,
icon: ArrowLeftRight,
onClick: toggleFormat,
},
]}
editorProps={{
height: '100%',
defaultLanguage: 'dbml',
beforeMount: setupDBMLLanguage,
loading: false,
theme: getEditorTheme(effectiveTheme),
options: {
wordWrap: 'off',
mouseWheelZoom: false,
domReadOnly: true,
},
}}
/>
<>
<CodeSnippet
code={editedDbml}
loading={isLoading}
actionsTooltipSide="right"
className="my-0.5"
allowCopy={!isEditMode}
actions={
isEditMode && hasDiff
? [
{
label: 'Accept Changes',
icon: Check,
onClick: acceptChanges,
className:
'h-7 items-center gap-1.5 rounded-md border border-green-200 bg-green-50 px-2.5 py-1.5 text-xs font-medium text-green-600 shadow-sm hover:bg-green-100 dark:border-green-800 dark:bg-green-800 dark:text-green-200 dark:hover:bg-green-700',
},
{
label: 'Undo Changes',
icon: Undo2,
onClick: undoChanges,
className:
'h-7 items-center gap-1.5 rounded-md border border-red-200 bg-red-50 px-2.5 py-1.5 text-xs font-medium text-red-600 shadow-sm hover:bg-red-100 dark:border-red-800 dark:bg-red-800 dark:text-red-200 dark:hover:bg-red-700',
},
]
: isEditMode && !hasDiff
? [
{
label: 'View',
icon: PencilOff,
onClick: () =>
setIsEditMode((prev) => !prev),
},
]
: [
{
label: `Show ${dbmlFormat === 'inline' ? 'Standard' : 'Inline'} Refs`,
icon: ArrowLeftRight,
onClick: toggleFormat,
},
{
label: 'Edit',
icon: Pencil,
onClick: () =>
setIsEditMode((prev) => !prev),
className: isEditButtonEmphasized
? 'dbml-edit-button-emphasis'
: undefined,
},
]
}
editorProps={{
height: '100%',
defaultLanguage: 'dbml',
beforeMount: setupDBMLLanguage,
theme: getEditorTheme(effectiveTheme),
onMount: handleEditorDidMount,
options: {
wordWrap: 'off',
mouseWheelZoom: false,
readOnly: !isEditMode,
},
onChange: (value) => {
setEditedDbml(value ?? '');
},
}}
/>
{warningMessage ? (
<div className="my-2 rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-900/50 dark:bg-blue-950/20">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-blue-600 dark:text-blue-400" />
<div className="flex-1">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
Warning
</p>
<p className="mt-0.5 text-xs text-blue-700 dark:text-blue-300">
{warningMessage}
</p>
</div>
<button
onClick={() => setWarningMessage(undefined)}
className="rounded p-0.5 text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-blue-900/50"
aria-label="Close warning"
>
<X className="size-3.5" />
</button>
</div>
</div>
) : null}
{errorMessage ? (
<div className="my-2 rounded-md border border-orange-200 bg-orange-50 p-3 dark:border-orange-900/50 dark:bg-orange-950/20">
<div className="flex gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-orange-600 dark:text-orange-400" />
<div className="flex-1">
<p className="text-sm font-medium text-orange-800 dark:text-orange-200">
Syntax Error
</p>
<p className="mt-0.5 text-xs text-orange-700 dark:text-orange-300">
{errorMessage ||
t('import_dbml_dialog.error.description')}
</p>
</div>
</div>
</div>
) : null}
</>
);
};