Compare commits

...

18 Commits

Author SHA1 Message Date
johnnyfish
0a99c0b96f fix: add postgres array type support for import and export 2025-10-23 19:40:43 +03:00
Guy Ben-Aharon
9e8979d062 update translate (#957) 2025-10-21 15:44:49 +03:00
Guy Ben-Aharon
9ed27cf30c fix: preserve multi-word types in DBML export/import (#956)
* fix: preserve multi-word types in DBML export/import

* fix
2025-10-21 15:10:02 +03:00
Guy Ben-Aharon
2c4b344efb fix: resolve dbml increment & nullable attributes issue (#954)
* fix: resolve dbml increment attribute

* fix nullable

* fix
2025-10-21 12:31:32 +03:00
Guy Ben-Aharon
ccb29e0a57 fix: resolve canvas filter tree state issues (#953) 2025-10-20 17:12:15 +03:00
Guy Ben-Aharon
7d811de097 fix: add open table in editor from canvas edit (#952) 2025-10-20 15:56:04 +03:00
Guy Ben-Aharon
62dec48572 fix: use flag for custom types (#951) 2025-10-19 18:54:08 +03:00
Jonathan Fishner
49328d8fbd fix: add support for arrays (#949)
* feat: add array field support with diff visualization

* some refactor

* fix

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-10-19 17:47:39 +03:00
Jonathan Fishner
459698b5d0 fix: add support for parsing default values in DBML (#948) 2025-10-16 21:07:55 +03:00
Guy Ben-Aharon
7ad0e7712d fix: manipulate schema directly from the canvas (#947) 2025-10-16 17:37:20 +03:00
Guy Ben-Aharon
34475add32 feat: create relationships on canvas modal (#946)
* feat: create relationships on canvas modal

* feat: create relationships on canvas modal

* feat: create relationships on canvas modal

* fix

* fix

* fix

* fix
2025-10-13 18:08:28 +03:00
Guy Ben-Aharon
38fedcec0c fix: exit table edit on area click (#945)
* fix: exit table edit on area click

* fix
2025-10-10 19:38:43 +03:00
Guy Ben-Aharon
498655e7b7 fix: prevent text input glitch when editing table field names (#944) 2025-10-10 15:22:09 +03:00
Jonathan Fishner
bcd8aa9378 fix: auto-enter edit mode when creating new tables from canvas (#943)
* fix: auto-enter edit mode when creating new tables from canvas or sidebar

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-10-09 15:43:41 +03:00
Jonathan Fishner
b15bc945ac fix: add timestampz and int as datatypes to postgres (#940) 2025-10-09 15:05:33 +03:00
Guy Ben-Aharon
c3c646bf7c fix: add rels export dbml (#937) 2025-09-28 20:27:41 +03:00
Jonathan Fishner
57b3b8777f fix: add auto-increment field detection in smart-query import (#935) 2025-09-28 17:09:02 +03:00
Guy Ben-Aharon
bb033091b1 fix: dbml diff fields types preview (#934) 2025-09-28 17:02:56 +03:00
88 changed files with 3621 additions and 446 deletions

View File

@@ -58,6 +58,7 @@ export interface SelectBoxProps {
footerButtons?: React.ReactNode;
commandOnMouseDown?: (e: React.MouseEvent) => void;
commandOnClick?: (e: React.MouseEvent) => void;
onSearchChange?: (search: string) => void;
}
export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
@@ -87,6 +88,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
footerButtons,
commandOnMouseDown,
commandOnClick,
onSearchChange,
},
ref
) => {
@@ -240,6 +242,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
<CommandItem
className="flex items-center"
key={option.value}
value={option.label}
keywords={option.regex ? [option.regex] : undefined}
onSelect={() =>
handleSelect(
@@ -404,7 +407,10 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
<div className="relative">
<CommandInput
value={searchTerm}
onValueChange={(e) => setSearchTerm(e)}
onValueChange={(e) => {
setSearchTerm(e);
onSearchChange?.(e);
}}
ref={ref}
placeholder={inputPlaceholder ?? 'Search...'}
className="h-9"

View File

@@ -42,6 +42,7 @@ interface TreeViewProps<
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
loadingNodeIds?: string[];
disableCache?: boolean;
}
export function TreeView<
@@ -62,12 +63,14 @@ export function TreeView<
renderHoverComponent,
renderActionsComponent,
loadingNodeIds,
disableCache = false,
}: TreeViewProps<Type, Context>) {
const { expanded, loading, loadedChildren, hasMoreChildren, toggleNode } =
useTree({
fetchChildren,
expanded: expandedProp,
setExpanded: setExpandedProp,
disableCache,
});
const [selectedIdInternal, setSelectedIdInternal] = React.useState<
string | undefined
@@ -145,6 +148,7 @@ export function TreeView<
renderHoverComponent={renderHoverComponent}
renderActionsComponent={renderActionsComponent}
loadingNodeIds={loadingNodeIds}
disableCache={disableCache}
/>
))}
</div>
@@ -179,6 +183,7 @@ interface TreeNodeProps<
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
loadingNodeIds?: string[];
disableCache?: boolean;
}
function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
@@ -201,11 +206,16 @@ function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
renderHoverComponent,
renderActionsComponent,
loadingNodeIds,
disableCache = false,
}: TreeNodeProps<Type, Context>) {
const [isHovered, setIsHovered] = useState(false);
const isExpanded = expanded[node.id];
const isLoading = loading[node.id];
const children = loadedChildren[node.id] || node.children;
// If cache is disabled, always use fresh node.children
// Otherwise, use cached loadedChildren if available (for async fetched data)
const children = disableCache
? node.children
: node.children || loadedChildren[node.id];
const isSelected = selectedId === node.id;
const IconComponent =
@@ -423,6 +433,7 @@ function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
renderHoverComponent={renderHoverComponent}
renderActionsComponent={renderActionsComponent}
loadingNodeIds={loadingNodeIds}
disableCache={disableCache}
/>
))}
{isLoading ? (

View File

@@ -28,10 +28,12 @@ export function useTree<
fetchChildren,
expanded: expandedProp,
setExpanded: setExpandedProp,
disableCache = false,
}: {
fetchChildren?: FetchChildrenFunction<Type, Context>;
expanded?: ExpandedState;
setExpanded?: Dispatch<SetStateAction<ExpandedState>>;
disableCache?: boolean;
}) {
const [expandedInternal, setExpandedInternal] = useState<ExpandedState>({});
@@ -89,8 +91,8 @@ export function useTree<
// Get any previously fetched children
const previouslyFetchedChildren = loadedChildren[nodeId] || [];
// If we have static children, merge them with any previously fetched children
if (staticChildren?.length) {
// Only cache if caching is enabled
if (!disableCache && staticChildren?.length) {
const mergedChildren = mergeChildren(
staticChildren,
previouslyFetchedChildren
@@ -110,8 +112,8 @@ export function useTree<
// Set expanded state immediately to show static/previously fetched children
setExpanded((prev) => ({ ...prev, [nodeId]: true }));
// If we haven't loaded dynamic children yet
if (!previouslyFetchedChildren.length) {
// If we haven't loaded dynamic children yet and cache is enabled
if (!disableCache && !previouslyFetchedChildren.length) {
setLoading((prev) => ({ ...prev, [nodeId]: true }));
try {
const fetchedChildren = await fetchChildren?.(
@@ -140,7 +142,14 @@ export function useTree<
}
}
},
[expanded, loadedChildren, fetchChildren, mergeChildren, setExpanded]
[
expanded,
loadedChildren,
fetchChildren,
mergeChildren,
setExpanded,
disableCache,
]
);
return {

View File

@@ -24,6 +24,31 @@ export interface CanvasContext {
fieldId?: string;
} | null>
>;
tempFloatingEdge: {
sourceNodeId: string;
targetNodeId?: string;
} | null;
setTempFloatingEdge: React.Dispatch<
React.SetStateAction<{
sourceNodeId: string;
targetNodeId?: string;
} | null>
>;
startFloatingEdgeCreation: ({
sourceNodeId,
}: {
sourceNodeId: string;
}) => void;
endFloatingEdgeCreation: () => void;
hoveringTableId: string | null;
setHoveringTableId: React.Dispatch<React.SetStateAction<string | null>>;
showCreateRelationshipNode: (params: {
sourceTableId: string;
targetTableId: string;
x: number;
y: number;
}) => void;
hideCreateRelationshipNode: () => void;
}
export const canvasContext = createContext<CanvasContext>({
@@ -35,4 +60,12 @@ export const canvasContext = createContext<CanvasContext>({
showFilter: false,
editTableModeTable: null,
setEditTableModeTable: emptyFn,
tempFloatingEdge: null,
setTempFloatingEdge: emptyFn,
startFloatingEdgeCreation: emptyFn,
endFloatingEdgeCreation: emptyFn,
hoveringTableId: null,
setHoveringTableId: emptyFn,
showCreateRelationshipNode: emptyFn,
hideCreateRelationshipNode: emptyFn,
});

View File

@@ -5,6 +5,7 @@ import React, {
useEffect,
useRef,
} from 'react';
import type { CanvasContext } from './canvas-context';
import { canvasContext } from './canvas-context';
import { useChartDB } from '@/hooks/use-chartdb';
import { adjustTablePositions } from '@/lib/domain/db-table';
@@ -15,6 +16,10 @@ import { createGraph } from '@/lib/graph';
import { useDiagramFilter } from '../diagram-filter-context/use-diagram-filter';
import { filterTable } from '@/lib/domain/diagram-filter/filter';
import { defaultSchemas } from '@/lib/data/default-schemas';
import {
CREATE_RELATIONSHIP_NODE_ID,
type CreateRelationshipNodeType,
} from '@/pages/editor-page/canvas/create-relationship-node/create-relationship-node';
interface CanvasProviderProps {
children: ReactNode;
@@ -30,7 +35,7 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
diagramId,
} = useChartDB();
const { filter, loading: filterLoading } = useDiagramFilter();
const { fitView } = useReactFlow();
const { fitView, screenToFlowPosition, setNodes } = useReactFlow();
const [overlapGraph, setOverlapGraph] =
useState<Graph<string>>(createGraph());
const [editTableModeTable, setEditTableModeTable] = useState<{
@@ -39,6 +44,12 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
} | null>(null);
const [showFilter, setShowFilter] = useState(false);
const [tempFloatingEdge, setTempFloatingEdge] =
useState<CanvasContext['tempFloatingEdge']>(null);
const [hoveringTableId, setHoveringTableId] = useState<string | null>(null);
const diagramIdActiveFilterRef = useRef<string>();
useEffect(() => {
@@ -122,6 +133,66 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
]
);
const startFloatingEdgeCreation: CanvasContext['startFloatingEdgeCreation'] =
useCallback(({ sourceNodeId }) => {
setShowFilter(false);
setTempFloatingEdge({
sourceNodeId,
});
}, []);
const endFloatingEdgeCreation: CanvasContext['endFloatingEdgeCreation'] =
useCallback(() => {
setTempFloatingEdge(null);
}, []);
const hideCreateRelationshipNode: CanvasContext['hideCreateRelationshipNode'] =
useCallback(() => {
setNodes((nds) =>
nds.filter((n) => n.id !== CREATE_RELATIONSHIP_NODE_ID)
);
endFloatingEdgeCreation();
}, [setNodes, endFloatingEdgeCreation]);
const showCreateRelationshipNode: CanvasContext['showCreateRelationshipNode'] =
useCallback(
({ sourceTableId, targetTableId, x, y }) => {
setTempFloatingEdge((edge) =>
edge
? {
...edge,
targetNodeId: targetTableId,
}
: null
);
const cursorPos = screenToFlowPosition({
x,
y,
});
const newNode: CreateRelationshipNodeType = {
id: CREATE_RELATIONSHIP_NODE_ID,
type: 'create-relationship',
position: cursorPos,
data: {
sourceTableId,
targetTableId,
},
draggable: true,
selectable: false,
zIndex: 1000,
};
setNodes((nds) => {
const nodesWithoutOldCreateRelationshipNode = nds.filter(
(n) => n.id !== CREATE_RELATIONSHIP_NODE_ID
);
return [...nodesWithoutOldCreateRelationshipNode, newNode];
});
},
[screenToFlowPosition, setNodes]
);
return (
<canvasContext.Provider
value={{
@@ -133,6 +204,14 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
showFilter,
editTableModeTable,
setEditTableModeTable,
tempFloatingEdge: tempFloatingEdge,
setTempFloatingEdge: setTempFloatingEdge,
startFloatingEdgeCreation: startFloatingEdgeCreation,
endFloatingEdgeCreation: endFloatingEdgeCreation,
hoveringTableId,
setHoveringTableId,
showCreateRelationshipNode,
hideCreateRelationshipNode,
}}
>
{children}

View File

@@ -74,10 +74,10 @@ export const ChartDBProvider: React.FC<
useState<string>();
const diffCalculatedHandler = useCallback((event: DiffCalculatedEvent) => {
const { tablesAdded, fieldsAdded, relationshipsAdded } = event.data;
const { tablesToAdd, fieldsToAdd, relationshipsToAdd } = event.data;
setTables((tables) =>
[...tables, ...(tablesAdded ?? [])].map((table) => {
const fields = fieldsAdded.get(table.id);
[...tables, ...(tablesToAdd ?? [])].map((table) => {
const fields = fieldsToAdd.get(table.id);
return fields
? { ...table, fields: [...table.fields, ...fields] }
: table;
@@ -85,7 +85,7 @@ export const ChartDBProvider: React.FC<
);
setRelationships((relationships) => [
...relationships,
...(relationshipsAdded ?? []),
...(relationshipsToAdd ?? []),
]);
}, []);
@@ -350,6 +350,7 @@ export const ChartDBProvider: React.FC<
isView: false,
order: tables.length,
...attributes,
schema: attributes?.schema ?? defaultSchemas[databaseType],
};
table.indexes = getTableIndexesWithPrimaryKey({

View File

@@ -15,9 +15,9 @@ export type DiffEventBase<T extends DiffEventType, D> = {
};
export type DiffCalculatedData = {
tablesAdded: DBTable[];
fieldsAdded: Map<string, DBField[]>;
relationshipsAdded: DBRelationship[];
tablesToAdd: DBTable[];
fieldsToAdd: Map<string, DBField[]>;
relationshipsToAdd: DBRelationship[];
};
export type DiffCalculatedEvent = DiffEventBase<
@@ -44,15 +44,21 @@ export interface DiffContext {
options?: {
summaryOnly?: boolean;
};
}) => void;
}) => { foundDiff: boolean };
resetDiff: () => void;
// table diff
checkIfTableHasChange: ({ tableId }: { tableId: string }) => boolean;
checkIfNewTable: ({ tableId }: { tableId: string }) => boolean;
checkIfTableRemoved: ({ tableId }: { tableId: string }) => boolean;
getTableNewName: ({ tableId }: { tableId: string }) => string | null;
getTableNewColor: ({ tableId }: { tableId: string }) => string | null;
getTableNewName: ({ tableId }: { tableId: string }) => {
old: string;
new: string;
} | null;
getTableNewColor: ({ tableId }: { tableId: string }) => {
old: string;
new: string;
} | null;
// field diff
checkIfFieldHasChange: ({
@@ -64,17 +70,46 @@ export interface DiffContext {
}) => boolean;
checkIfFieldRemoved: ({ fieldId }: { fieldId: string }) => boolean;
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;
getFieldNewName: ({
fieldId,
}: {
fieldId: string;
}) => { old: string; new: string } | null;
getFieldNewType: ({
fieldId,
}: {
fieldId: string;
}) => { old: DataType; new: DataType } | null;
getFieldNewPrimaryKey: ({
fieldId,
}: {
fieldId: string;
}) => { old: boolean; new: boolean } | null;
getFieldNewNullable: ({
fieldId,
}: {
fieldId: string;
}) => { old: boolean; new: boolean } | null;
getFieldNewCharacterMaximumLength: ({
fieldId,
}: {
fieldId: string;
}) => string | null;
getFieldNewScale: ({ fieldId }: { fieldId: string }) => number | null;
getFieldNewPrecision: ({ fieldId }: { fieldId: string }) => number | null;
}) => { old: string; new: string } | null;
getFieldNewScale: ({
fieldId,
}: {
fieldId: string;
}) => { old: number; new: number } | null;
getFieldNewPrecision: ({
fieldId,
}: {
fieldId: string;
}) => { old: number; new: number } | null;
getFieldNewIsArray: ({
fieldId,
}: {
fieldId: string;
}) => { old: boolean; new: boolean } | null;
// relationship diff
checkIfNewRelationship: ({

View File

@@ -36,7 +36,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
const events = useEventEmitter<DiffEvent>();
const generateNewFieldsMap = useCallback(
const generateFieldsToAddMap = useCallback(
({
diffMap,
newDiagram,
@@ -66,7 +66,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
[]
);
const findNewRelationships = useCallback(
const findRelationshipsToAdd = useCallback(
({
diffMap,
newDiagram,
@@ -101,7 +101,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
diffMap: DiffMap;
}): DiffCalculatedData => {
return {
tablesAdded:
tablesToAdd:
newDiagram?.tables?.filter((table) => {
const tableKey = getDiffMapKey({
diffObject: 'table',
@@ -114,17 +114,17 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
);
}) ?? [],
fieldsAdded: generateNewFieldsMap({
fieldsToAdd: generateFieldsToAddMap({
diffMap: diffMap,
newDiagram: newDiagram,
}),
relationshipsAdded: findNewRelationships({
relationshipsToAdd: findRelationshipsToAdd({
diffMap: diffMap,
newDiagram: newDiagram,
}),
};
},
[findNewRelationships, generateNewFieldsMap]
[findRelationshipsToAdd, generateFieldsToAddMap]
);
const calculateDiff: DiffContext['calculateDiff'] = useCallback(
@@ -149,6 +149,8 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
newDiagram: newDiagramArg,
}),
});
return { foundDiff: !!newDiffs.size };
},
[setDiffMap, events, generateDiffCalculatedData]
);
@@ -165,7 +167,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
const diff = diffMap.get(tableNameKey);
if (diff?.type === 'changed') {
return diff.newValue as string;
return {
new: diff.newValue as string,
old: diff.oldValue as string,
};
}
}
@@ -186,7 +191,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
const diff = diffMap.get(tableColorKey);
if (diff?.type === 'changed') {
return diff.newValue as string;
return {
new: diff.newValue as string,
old: diff.oldValue as string,
};
}
}
return null;
@@ -277,7 +285,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as string;
return {
old: diff.oldValue as string,
new: diff.newValue as string,
};
}
}
@@ -298,7 +309,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as DataType;
return {
old: diff.oldValue as DataType,
new: diff.newValue as DataType,
};
}
}
@@ -321,7 +335,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as boolean;
return {
old: diff.oldValue as boolean,
new: diff.newValue as boolean,
};
}
}
@@ -342,7 +359,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as boolean;
return {
old: diff.oldValue as boolean,
new: diff.newValue as boolean,
};
}
}
@@ -365,7 +385,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as string;
return {
old: diff.oldValue as string,
new: diff.newValue as string,
};
}
}
@@ -386,7 +409,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as number;
return {
old: diff.oldValue as number,
new: diff.newValue as number,
};
}
}
@@ -409,7 +435,34 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as number;
return {
old: diff.oldValue as number,
new: diff.newValue as number,
};
}
}
return null;
},
[diffMap]
);
const getFieldNewIsArray = useCallback<DiffContext['getFieldNewIsArray']>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'isArray',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return {
old: diff.oldValue as boolean,
new: diff.newValue as boolean,
};
}
}
@@ -491,6 +544,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
getFieldNewCharacterMaximumLength,
getFieldNewScale,
getFieldNewPrecision,
getFieldNewIsArray,
// relationship diff
checkIfNewRelationship,

View File

@@ -140,7 +140,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
if (importMethod === 'dbml') {
// Validate DBML by parsing it
const validateResponse = verifyDBML(scriptResult);
const validateResponse = verifyDBML(scriptResult, { databaseType });
if (!validateResponse.hasError) {
setErrorMessage('');
setSqlValidation({

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState, useEffect } from 'react';
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
import { useChartDB } from './use-chartdb';
import { useDebounce } from './use-debounce-v2';
import type { DBField, DBTable } from '@/lib/domain';
import type { DatabaseType, DBField, DBTable } from '@/lib/domain';
import type {
SelectBoxOption,
SelectBoxProps,
@@ -9,49 +9,62 @@ import type {
import {
dataTypeDataToDataType,
sortedDataTypeMap,
supportsArrayDataType,
autoIncrementAlwaysOn,
requiresNotNull,
} from '@/lib/data/data-types/data-types';
import { generateDBFieldSuffix } from '@/lib/domain/db-field';
import type { DataTypeData } from '@/lib/data/data-types/data-types';
const generateFieldRegexPatterns = (
dataType: DataTypeData
dataType: DataTypeData,
databaseType: DatabaseType
): {
regex?: string;
extractRegex?: RegExp;
} => {
const typeName = dataType.name;
const supportsArrays = supportsArrayDataType(dataType.id, databaseType);
const arrayPattern = supportsArrays ? '(\\[\\])?' : '';
if (!dataType.fieldAttributes) {
return { regex: undefined, extractRegex: undefined };
// For types without field attributes, support plain type + optional array notation
return {
regex: `^${typeName}${arrayPattern}$`,
extractRegex: new RegExp(`^${typeName}${arrayPattern}$`),
};
}
const typeName = dataType.name;
const fieldAttributes = dataType.fieldAttributes;
if (fieldAttributes.hasCharMaxLength) {
if (fieldAttributes.hasCharMaxLengthOption) {
return {
regex: `^${typeName}\\((\\d+|[mM][aA][xX])\\)$`,
extractRegex: /\((\d+|max)\)/i,
regex: `^${typeName}\\((\\d+|[mM][aA][xX])\\)${arrayPattern}$`,
extractRegex: supportsArrays
? /\((\d+|max)\)(\[\])?/i
: /\((\d+|max)\)/i,
};
}
return {
regex: `^${typeName}\\(\\d+\\)$`,
extractRegex: /\((\d+)\)/,
regex: `^${typeName}\\(\\d+\\)${arrayPattern}$`,
extractRegex: supportsArrays ? /\((\d+)\)(\[\])?/ : /\((\d+)\)/,
};
}
if (fieldAttributes.precision && fieldAttributes.scale) {
return {
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)$`,
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)${arrayPattern}$`,
extractRegex: new RegExp(
`${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)`
`${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)${arrayPattern}`
),
};
}
if (fieldAttributes.precision) {
return {
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)$`,
extractRegex: /\((\d+)\)/,
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)${arrayPattern}$`,
extractRegex: supportsArrays ? /\((\d+)\)(\[\])?/ : /\((\d+)\)/,
};
}
@@ -75,12 +88,20 @@ export const useUpdateTableField = (
const [localNullable, setLocalNullable] = useState(field.nullable);
const [localPrimaryKey, setLocalPrimaryKey] = useState(field.primaryKey);
const lastFieldNameRef = useRef<string>(field.name);
useEffect(() => {
if (localFieldName === lastFieldNameRef.current) {
lastFieldNameRef.current = field.name;
setLocalFieldName(field.name);
}
}, [field.name, localFieldName]);
// Update local state when field properties change externally
useEffect(() => {
setLocalFieldName(field.name);
setLocalNullable(field.nullable);
setLocalPrimaryKey(field.primaryKey);
}, [field.name, field.nullable, field.primaryKey]);
}, [field.nullable, field.primaryKey]);
// Use custom updateField if provided, otherwise use the chartDB one
const updateField = useMemo(
@@ -110,7 +131,10 @@ export const useUpdateTableField = (
const standardTypes: SelectBoxOption[] = sortedDataTypeMap[
databaseType
].map((type) => {
const regexPatterns = generateFieldRegexPatterns(type);
const regexPatterns = generateFieldRegexPatterns(
type,
databaseType
);
return {
label: type.name,
@@ -154,8 +178,13 @@ export const useUpdateTableField = (
let characterMaximumLength: string | undefined = undefined;
let precision: number | undefined = undefined;
let scale: number | undefined = undefined;
let isArray: boolean | undefined = undefined;
if (regexMatches?.length) {
// Check if the last captured group is the array indicator []
const lastMatch = regexMatches[regexMatches.length - 1];
const hasArrayIndicator = lastMatch === '[]';
if (dataType?.fieldAttributes?.hasCharMaxLength) {
characterMaximumLength = regexMatches[1]?.toLowerCase();
} else if (
@@ -169,6 +198,17 @@ export const useUpdateTableField = (
} else if (dataType?.fieldAttributes?.precision) {
precision = parseInt(regexMatches[1]);
}
// Set isArray if the array indicator was found and the type supports arrays
if (hasArrayIndicator) {
const typeId = value as string;
if (supportsArrayDataType(typeId, databaseType)) {
isArray = true;
}
} else {
// Explicitly set to false/undefined if no array indicator
isArray = undefined;
}
} else {
if (
dataType?.fieldAttributes?.hasCharMaxLength &&
@@ -186,11 +226,17 @@ export const useUpdateTableField = (
}
}
const newTypeName = dataType?.name ?? (value as string);
const typeRequiresNotNull = requiresNotNull(newTypeName);
const shouldForceIncrement = autoIncrementAlwaysOn(newTypeName);
updateField(table.id, field.id, {
characterMaximumLength,
precision,
scale,
increment: undefined,
isArray,
...(typeRequiresNotNull ? { nullable: false } : {}),
increment: shouldForceIncrement ? true : undefined,
default: undefined,
type: dataTypeDataToDataType(
dataType ?? {
@@ -228,9 +274,16 @@ export const useUpdateTableField = (
const debouncedNullableUpdate = useDebounce(
useCallback(
(value: boolean) => {
updateField(table.id, field.id, { nullable: value });
const updates: Partial<DBField> = { nullable: value };
// If setting to nullable, clear increment (auto-increment requires NOT NULL)
if (value && field.increment) {
updates.increment = undefined;
}
updateField(table.id, field.id, updates);
},
[updateField, table.id, field.id]
[updateField, table.id, field.id, field.increment]
),
100 // 100ms debounce for toggle
);
@@ -291,11 +344,17 @@ export const useUpdateTableField = (
// Utility function to generate field suffix for display
const generateFieldSuffix = useCallback(
(typeId?: string) => {
return generateDBFieldSuffix(field, {
databaseType,
forceExtended: true,
typeId,
});
return generateDBFieldSuffix(
{
...field,
isArray: field.isArray && typeId === field.type.id,
},
{
databaseType,
forceExtended: true,
typeId,
}
);
},
[field, databaseType]
);

View File

@@ -308,7 +308,7 @@ export const ar: LanguageTranslation = {
cancel: 'إلغاء',
import_from_file: 'استيراد من ملف',
back: 'رجوع',
empty_diagram: 'مخطط فارغ',
empty_diagram: 'قاعدة بيانات فارغة',
continue: 'متابعة',
import: 'استيراد',
},

View File

@@ -310,7 +310,7 @@ export const bn: LanguageTranslation = {
cancel: 'বাতিল করুন',
back: 'ফিরে যান',
import_from_file: 'ফাইল থেকে আমদানি করুন',
empty_diagram: 'ফাঁকা চিত্র',
empty_diagram: 'খালি ডাটাবেস',
continue: 'চালিয়ে যান',
import: 'আমদানি করুন',
},

View File

@@ -313,7 +313,7 @@ export const de: LanguageTranslation = {
back: 'Zurück',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: 'Leeres Diagramm',
empty_diagram: 'Leere Datenbank',
continue: 'Weiter',
import: 'Importieren',
},

View File

@@ -301,7 +301,7 @@ export const en = {
cancel: 'Cancel',
import_from_file: 'Import from File',
back: 'Back',
empty_diagram: 'Empty diagram',
empty_diagram: 'Empty database',
continue: 'Continue',
import: 'Import',
},

View File

@@ -310,7 +310,7 @@ export const es: LanguageTranslation = {
back: 'Atrás',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: 'Diagrama vacío',
empty_diagram: 'Base de datos vacía',
continue: 'Continuar',
import: 'Importar',
},

View File

@@ -307,7 +307,7 @@ export const fr: LanguageTranslation = {
cancel: 'Annuler',
back: 'Retour',
import_from_file: "Importer à partir d'un fichier",
empty_diagram: 'Diagramme vide',
empty_diagram: 'Base de données vide',
continue: 'Continuer',
import: 'Importer',
},

View File

@@ -310,7 +310,7 @@ export const gu: LanguageTranslation = {
cancel: 'રદ કરો',
back: 'પાછા',
import_from_file: 'ફાઇલમાંથી આયાત કરો',
empty_diagram: 'ખાલી ડાયાગ્રામ',
empty_diagram: 'ખાલી ડેટાબેસ',
continue: 'ચાલુ રાખો',
import: 'આયાત કરો',
},

View File

@@ -312,7 +312,7 @@ export const hi: LanguageTranslation = {
back: 'वापस',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: 'खाली आरेख',
empty_diagram: 'खाली डेटाबेस',
continue: 'जारी रखें',
import: 'आयात करें',
},

View File

@@ -305,7 +305,7 @@ export const hr: LanguageTranslation = {
cancel: 'Odustani',
import_from_file: 'Uvezi iz datoteke',
back: 'Natrag',
empty_diagram: 'Prazan dijagram',
empty_diagram: 'Prazna baza podataka',
continue: 'Nastavi',
import: 'Uvezi',
},

View File

@@ -309,7 +309,7 @@ export const id_ID: LanguageTranslation = {
cancel: 'Batal',
import_from_file: 'Impor dari file',
back: 'Kembali',
empty_diagram: 'Diagram Kosong',
empty_diagram: 'Database Kosong',
continue: 'Lanjutkan',
import: 'Impor',
},

View File

@@ -314,7 +314,7 @@ export const ja: LanguageTranslation = {
back: '戻る',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: '空のダイアグラム',
empty_diagram: '空のデータベース',
continue: '続行',
import: 'インポート',
},

View File

@@ -309,7 +309,7 @@ export const ko_KR: LanguageTranslation = {
cancel: '취소',
back: '뒤로가기',
import_from_file: '파일에서 가져오기',
empty_diagram: '빈 다이어그램으로 시작',
empty_diagram: '빈 데이터베이스',
continue: '계속',
import: '가져오기',
},

View File

@@ -315,7 +315,7 @@ export const mr: LanguageTranslation = {
// TODO: Add translations
import_from_file: 'Import from File',
back: 'मागे',
empty_diagram: 'रिक्त आरेख',
empty_diagram: 'रिक्त डेटाबेस',
continue: 'सुरू ठेवा',
import: 'आयात करा',
},

View File

@@ -311,7 +311,7 @@ export const ne: LanguageTranslation = {
cancel: 'रद्द गर्नुहोस्',
import_from_file: 'फाइलबाट आयात गर्नुहोस्',
back: 'फर्क',
empty_diagram: 'रिक्त डायाग्राम',
empty_diagram: 'खाली डाटाबेस',
continue: 'जारी राख्नुहोस्',
import: 'आयात गर्नुहोस्',
},

View File

@@ -311,7 +311,7 @@ export const pt_BR: LanguageTranslation = {
back: 'Voltar',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: 'Diagrama vazio',
empty_diagram: 'Banco de dados vazio',
continue: 'Continuar',
import: 'Importar',
},

View File

@@ -307,7 +307,7 @@ export const ru: LanguageTranslation = {
cancel: 'Отменить',
back: 'Назад',
import_from_file: 'Импортировать из файла',
empty_diagram: 'Пустая диаграмма',
empty_diagram: 'Пустая база данных',
continue: 'Продолжить',
import: 'Импорт',
},

View File

@@ -312,7 +312,7 @@ export const te: LanguageTranslation = {
// TODO: Translate
import_from_file: 'Import from File',
back: 'తిరుగు',
empty_diagram: 'ఖాళీ చిత్రము',
empty_diagram: 'ఖాళీ డేటాబేస్',
continue: 'కొనసాగించు',
import: 'డిగుమతి',
},

View File

@@ -308,7 +308,7 @@ export const tr: LanguageTranslation = {
import_from_file: 'Import from File',
cancel: 'İptal',
back: 'Geri',
empty_diagram: 'Boş diyagram',
empty_diagram: 'Boş veritabanı',
continue: 'Devam',
import: 'İçe Aktar',
},

View File

@@ -308,7 +308,7 @@ export const uk: LanguageTranslation = {
cancel: 'Скасувати',
back: 'Назад',
import_from_file: 'Імпортувати з файлу',
empty_diagram: 'Порожня діаграма',
empty_diagram: 'Порожня база даних',
continue: 'Продовжити',
import: 'Імпорт',
},

View File

@@ -309,7 +309,7 @@ export const vi: LanguageTranslation = {
cancel: 'Hủy',
import_from_file: 'Nhập từ tệp',
back: 'Trở lại',
empty_diagram: 'Sơ đồ trống',
empty_diagram: 'Cơ sở dữ liệu trống',
continue: 'Tiếp tục',
import: 'Nhập',
},

View File

@@ -306,7 +306,7 @@ export const zh_CN: LanguageTranslation = {
cancel: '取消',
import_from_file: '从文件导入',
back: '上一步',
empty_diagram: '新建空关系图',
empty_diagram: '空数据库',
continue: '下一步',
import: '导入',
},

View File

@@ -305,7 +305,7 @@ export const zh_TW: LanguageTranslation = {
cancel: '取消',
import_from_file: '從檔案匯入',
back: '返回',
empty_diagram: '空白圖表',
empty_diagram: '空資料庫',
continue: '繼續',
import: '匯入',
},

View File

@@ -129,9 +129,6 @@ export const clickhouseDataTypes: readonly DataTypeData[] = [
{ name: 'enum', id: 'enum' },
{ name: 'lowcardinality', id: 'lowcardinality' },
// Array Type
{ name: 'array', id: 'array' },
// Tuple Type
{ name: 'tuple', id: 'tuple' },
{ name: 'map', id: 'map' },

View File

@@ -1,5 +1,6 @@
import { z } from 'zod';
import { DatabaseType } from '../../domain/database-type';
import { databaseSupportsArrays } from '../../domain/database-capabilities';
import { clickhouseDataTypes } from './clickhouse-data-types';
import { genericDataTypes } from './generic-data-types';
import { mariadbDataTypes } from './mariadb-data-types';
@@ -165,3 +166,34 @@ export const supportsAutoIncrementDataType = (
'decimal',
].includes(dataTypeName.toLocaleLowerCase());
};
export const autoIncrementAlwaysOn = (dataTypeName: string): boolean => {
return ['serial', 'bigserial', 'smallserial'].includes(
dataTypeName.toLowerCase()
);
};
export const requiresNotNull = (dataTypeName: string): boolean => {
return ['serial', 'bigserial', 'smallserial'].includes(
dataTypeName.toLowerCase()
);
};
const ARRAY_INCOMPATIBLE_TYPES = [
'serial',
'bigserial',
'smallserial',
] as const;
export const supportsArrayDataType = (
dataTypeName: string,
databaseType: DatabaseType
): boolean => {
if (!databaseSupportsArrays(databaseType)) {
return false;
}
return !ARRAY_INCOMPATIBLE_TYPES.includes(
dataTypeName.toLowerCase() as (typeof ARRAY_INCOMPATIBLE_TYPES)[number]
);
};

View File

@@ -12,6 +12,7 @@ export const postgresDataTypes: readonly DataTypeData[] = [
{ name: 'text', id: 'text', usageLevel: 1 },
{ name: 'boolean', id: 'boolean', usageLevel: 1 },
{ name: 'timestamp', id: 'timestamp', usageLevel: 1 },
{ name: 'timestamptz', id: 'timestamptz', usageLevel: 1 },
{ name: 'date', id: 'date', usageLevel: 1 },
// Level 2 - Second most common types
@@ -42,6 +43,7 @@ export const postgresDataTypes: readonly DataTypeData[] = [
id: 'timestamp_with_time_zone',
usageLevel: 2,
},
{ name: 'int', id: 'int', usageLevel: 2 },
// Less common types
{
@@ -95,7 +97,6 @@ export const postgresDataTypes: readonly DataTypeData[] = [
{ name: 'tsvector', id: 'tsvector' },
{ name: 'tsquery', id: 'tsquery' },
{ name: 'xml', id: 'xml' },
{ name: 'array', id: 'array' },
{ name: 'int4range', id: 'int4range' },
{ name: 'int8range', id: 'int8range' },
{ name: 'numrange', id: 'numrange' },

View File

@@ -57,6 +57,10 @@ export const createFieldsFromMetadata = ({
...(col.precision?.scale ? { scale: col.precision.scale } : {}),
...(col.default ? { default: col.default } : {}),
...(col.collation ? { collation: col.collation } : {}),
...(col.is_identity !== undefined
? { increment: col.is_identity }
: {}),
...(col.is_array !== undefined ? { isArray: col.is_array } : {}),
createdAt: Date.now(),
comments: col.comment ? col.comment : undefined,
})

View File

@@ -15,6 +15,8 @@ export interface ColumnInfo {
default?: string | null; // Default value for the column, nullable
collation?: string | null;
comment?: string | null;
is_identity?: boolean; // Indicates if the column is auto-increment/identity
is_array?: boolean; // Indicates if the column is an array type
}
export const ColumnInfoSchema: z.ZodType<ColumnInfo> = z.object({
@@ -35,4 +37,6 @@ export const ColumnInfoSchema: z.ZodType<ColumnInfo> = z.object({
default: z.string().nullable().optional(),
collation: z.string().nullable().optional(),
comment: z.string().nullable().optional(),
is_identity: z.boolean().optional(),
is_array: z.boolean().optional(),
});

View File

@@ -127,7 +127,13 @@ cols AS (
',"default":"', null,
'","collation":"', COALESCE(cols.COLLATION_NAME::TEXT, ''),
'","comment":"', COALESCE(replace(replace(dsc.description::TEXT, '"', '\\"'), '\\x', '\\\\x'), ''),
'"}')), ',') AS cols_metadata
'","is_identity":', CASE
WHEN cols.is_identity = 'YES' THEN 'true'
WHEN cols.column_default IS NOT NULL AND cols.column_default LIKE 'nextval(%' THEN 'true'
WHEN cols.column_default LIKE 'unique_rowid()%' THEN 'true'
ELSE 'false'
END,
'}')), ',') AS cols_metadata
FROM information_schema.columns cols
LEFT JOIN pg_catalog.pg_class c
ON c.relname = cols.table_name

View File

@@ -69,7 +69,9 @@ SELECT CAST(CONCAT(
',"ordinal_position":', cols.ordinal_position,
',"nullable":', IF(cols.is_nullable = 'YES', 'true', 'false'),
',"default":"', ${withExtras ? withDefault : withoutDefault},
'","collation":"', IFNULL(cols.collation_name, ''), '"}')
'","collation":"', IFNULL(cols.collation_name, ''),
'","is_identity":', IF(cols.extra LIKE '%auto_increment%', 'true', 'false'),
'"}')
) FROM (
SELECT cols.table_schema,
cols.table_name,
@@ -81,7 +83,8 @@ SELECT CAST(CONCAT(
cols.ordinal_position,
cols.is_nullable,
cols.column_default,
cols.collation_name
cols.collation_name,
cols.extra
FROM information_schema.columns cols
WHERE cols.table_schema = DATABASE()
) AS cols), ''),

View File

@@ -92,7 +92,9 @@ export const getMySQLQuery = (
',"ordinal_position":', cols.ordinal_position,
',"nullable":', IF(cols.is_nullable = 'YES', 'true', 'false'),
',"default":"', ${withExtras ? withDefault : withoutDefault},
'","collation":"', IFNULL(cols.collation_name, ''), '"}'
'","collation":"', IFNULL(cols.collation_name, ''),
'","is_identity":', IF(cols.extra LIKE '%auto_increment%', 'true', 'false'),
'}'
)))))
), indexes as (
(SELECT (@indexes:=NULL),

View File

@@ -181,7 +181,13 @@ cols AS (
'","table":"', cols.table_name,
'","name":"', cols.column_name,
'","ordinal_position":', cols.ordinal_position,
',"type":"', case when LOWER(replace(cols.data_type, '"', '')) = 'user-defined' then pg_type.typname else LOWER(replace(cols.data_type, '"', '')) end,
',"type":"', CASE WHEN cols.data_type = 'ARRAY' THEN
format_type(pg_type.typelem, NULL)
WHEN LOWER(replace(cols.data_type, '"', '')) = 'user-defined' THEN
format_type(pg_type.oid, NULL)
ELSE
LOWER(replace(cols.data_type, '"', ''))
END,
'","character_maximum_length":"', COALESCE(cols.character_maximum_length::text, 'null'),
'","precision":',
CASE
@@ -194,7 +200,16 @@ cols AS (
',"default":"', ${withExtras ? withDefault : withoutDefault},
'","collation":"', COALESCE(cols.COLLATION_NAME, ''),
'","comment":"', ${withExtras ? withComments : withoutComments},
'"}')), ',') AS cols_metadata
'","is_identity":', CASE
WHEN cols.is_identity = 'YES' THEN 'true'
WHEN cols.column_default IS NOT NULL AND cols.column_default LIKE 'nextval(%' THEN 'true'
ELSE 'false'
END,
',"is_array":', CASE
WHEN cols.data_type = 'ARRAY' OR pg_type.typelem > 0 THEN 'true'
ELSE 'false'
END,
'}')), ',') AS cols_metadata
FROM information_schema.columns cols
LEFT JOIN pg_catalog.pg_class c
ON c.relname = cols.table_name
@@ -206,6 +221,8 @@ cols AS (
ON attr.attrelid = c.oid AND attr.attname = cols.column_name
LEFT JOIN pg_catalog.pg_type
ON pg_type.oid = attr.atttypid
LEFT JOIN pg_catalog.pg_type AS elem_type
ON elem_type.oid = pg_type.typelem
WHERE cols.table_schema NOT IN ('information_schema', 'pg_catalog')${
databaseEdition === DatabaseEdition.POSTGRESQL_TIMESCALE
? timescaleColFilter

View File

@@ -119,7 +119,13 @@ WITH fk_info AS (
END
ELSE null
END,
'default', ${withExtras ? withDefault : withoutDefault}
'default', ${withExtras ? withDefault : withoutDefault},
'is_identity',
CASE
WHEN p.pk = 1 AND LOWER(p.type) LIKE '%int%' THEN json('true')
WHEN LOWER((SELECT sql FROM sqlite_master WHERE name = m.name)) LIKE '%' || p.name || '%autoincrement%' THEN json('true')
ELSE json('false')
END
)
) AS cols_metadata
FROM
@@ -292,7 +298,13 @@ WITH fk_info AS (
END
ELSE null
END,
'default', ${withExtras ? withDefault : withoutDefault}
'default', ${withExtras ? withDefault : withoutDefault},
'is_identity',
CASE
WHEN p.pk = 1 AND LOWER(p.type) LIKE '%int%' THEN json('true')
WHEN LOWER((SELECT sql FROM sqlite_master WHERE name = m.name)) LIKE '%' || p.name || '%autoincrement%' THEN json('true')
ELSE json('false')
END
)
) AS cols_metadata
FROM

View File

@@ -91,6 +91,11 @@ cols AS (
WHEN cols.COLLATION_NAME IS NULL THEN 'null'
ELSE '"' + STRING_ESCAPE(cols.COLLATION_NAME, 'json') + '"'
END +
', "is_identity": ' + CASE
WHEN COLUMNPROPERTY(OBJECT_ID(cols.TABLE_SCHEMA + '.' + cols.TABLE_NAME), cols.COLUMN_NAME, 'IsIdentity') = 1
THEN 'true'
ELSE 'false'
END +
N'}') COLLATE DATABASE_DEFAULT
), N','
) +

View File

@@ -0,0 +1,356 @@
import { describe, it, expect } from 'vitest';
import { generateId } from '@/lib/utils';
import { exportBaseSQL } from '../export-sql-script';
import { DatabaseType } from '@/lib/domain/database-type';
import type { Diagram } from '@/lib/domain/diagram';
describe('SQL Export - Array Fields (Fantasy RPG Theme)', () => {
it('should export array fields for magical spell components', () => {
const diagram: Diagram = {
id: 'test-diagram',
name: 'Magical Spell System',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'spells',
schema: '',
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'name',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: false,
createdAt: Date.now(),
characterMaximumLength: '200',
},
{
id: generateId(),
name: 'components',
type: { id: 'text', name: 'text' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
isArray: true,
comments: 'Magical components needed for the spell',
},
{
id: generateId(),
name: 'elemental_types',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
characterMaximumLength: '50',
isArray: true,
comments:
'Elements involved: fire, water, earth, air',
},
],
indexes: [],
x: 0,
y: 0,
color: '#3b82f6',
isView: false,
createdAt: Date.now(),
order: 0,
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
expect(sql).toContain('CREATE TABLE "spells"');
expect(sql).toContain('"components" text[]');
expect(sql).toContain('"elemental_types" varchar(50)[]');
});
it('should export array fields for hero inventory system', () => {
const diagram: Diagram = {
id: 'test-diagram',
name: 'RPG Inventory System',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'heroes',
schema: 'game',
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'bigint', name: 'bigint' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'name',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: false,
createdAt: Date.now(),
characterMaximumLength: '100',
},
{
id: generateId(),
name: 'abilities',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
characterMaximumLength: '100',
isArray: true,
comments:
'Special abilities like Stealth, Fireball, etc',
},
{
id: generateId(),
name: 'inventory_slots',
type: { id: 'integer', name: 'integer' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
isArray: true,
comments: 'Item IDs in inventory',
},
{
id: generateId(),
name: 'skill_levels',
type: { id: 'decimal', name: 'decimal' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
precision: 5,
scale: 2,
isArray: true,
comments: 'Skill proficiency levels',
},
],
indexes: [],
x: 0,
y: 0,
color: '#ef4444',
isView: false,
createdAt: Date.now(),
order: 0,
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
expect(sql).toContain('CREATE TABLE "game"."heroes"');
expect(sql).toContain('"abilities" varchar(100)[]');
expect(sql).toContain('"inventory_slots" integer[]');
expect(sql).toContain('"skill_levels" decimal(5, 2)[]');
});
it('should export non-array fields normally when isArray is false or undefined', () => {
const diagram: Diagram = {
id: 'test-diagram',
name: 'Quest System',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'quests',
schema: '',
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'title',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: false,
createdAt: Date.now(),
characterMaximumLength: '200',
isArray: false,
},
{
id: generateId(),
name: 'description',
type: { id: 'text', name: 'text' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
// isArray is undefined - should not be treated as array
},
],
indexes: [],
x: 0,
y: 0,
color: '#8b5cf6',
isView: false,
createdAt: Date.now(),
order: 0,
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
expect(sql).toContain('"title" varchar(200)');
expect(sql).not.toContain('"title" varchar(200)[]');
expect(sql).toContain('"description" text');
expect(sql).not.toContain('"description" text[]');
});
it('should handle mixed array and non-array fields in magical creatures table', () => {
const diagram: Diagram = {
id: 'test-diagram',
name: 'Bestiary System',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'magical_creatures',
schema: 'bestiary',
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'bigint', name: 'bigint' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'species_name',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: false,
createdAt: Date.now(),
characterMaximumLength: '100',
},
{
id: generateId(),
name: 'habitats',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
characterMaximumLength: '80',
isArray: true,
comments:
'Preferred habitats: forest, mountain, swamp',
},
{
id: generateId(),
name: 'danger_level',
type: { id: 'integer', name: 'integer' },
primaryKey: false,
unique: false,
nullable: false,
createdAt: Date.now(),
default: '1',
},
{
id: generateId(),
name: 'resistances',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
characterMaximumLength: '50',
isArray: true,
comments: 'Damage resistances',
},
{
id: generateId(),
name: 'is_tameable',
type: { id: 'boolean', name: 'boolean' },
primaryKey: false,
unique: false,
nullable: false,
createdAt: Date.now(),
default: 'false',
},
],
indexes: [],
x: 0,
y: 0,
color: '#10b981',
isView: false,
createdAt: Date.now(),
order: 0,
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
expect(sql).toContain('CREATE TABLE "bestiary"."magical_creatures"');
expect(sql).toContain('"species_name" varchar(100)');
expect(sql).not.toContain('"species_name" varchar(100)[]');
expect(sql).toContain('"habitats" varchar(80)[]');
expect(sql).toContain('"danger_level" integer');
expect(sql).not.toContain('"danger_level" integer[]');
expect(sql).toContain('"resistances" varchar(50)[]');
expect(sql).toContain('"is_tameable" boolean');
expect(sql).not.toContain('"is_tameable" boolean[]');
});
});

View File

@@ -286,10 +286,14 @@ export function exportPostgreSQL({
}
}
// Handle array types (check if the type name ends with '[]')
if (typeName.endsWith('[]')) {
typeWithSize =
typeWithSize.replace('[]', '') + '[]';
// Handle array types (check if isArray flag or if type name ends with '[]')
if (field.isArray || typeName.endsWith('[]')) {
// Remove any existing [] notation
const baseTypeWithoutArray = typeWithSize.replace(
/\[\]$/,
''
);
typeWithSize = baseTypeWithoutArray + '[]';
}
const notNull = field.nullable ? '' : ' NOT NULL';

View File

@@ -1,9 +1,6 @@
import type { Diagram } from '../../domain/diagram';
import { OPENAI_API_KEY, OPENAI_API_ENDPOINT, LLM_MODEL_NAME } from '@/lib/env';
import {
DatabaseType,
databaseTypesWithCommentSupport,
} from '@/lib/domain/database-type';
import { DatabaseType } from '@/lib/domain/database-type';
import type { DBTable } from '@/lib/domain/db-table';
import { dataTypeMap, type DataType } from '../data-types/data-types';
import { generateCacheKey, getFromCache, setInCache } from './export-sql-cache';
@@ -12,6 +9,59 @@ import { exportPostgreSQL } from './export-per-type/postgresql';
import { exportSQLite } from './export-per-type/sqlite';
import { exportMySQL } from './export-per-type/mysql';
import { escapeSQLComment } from './export-per-type/common';
import {
databaseTypesWithCommentSupport,
supportsCustomTypes,
} from '@/lib/domain/database-capabilities';
// Function to format default values with proper quoting
const formatDefaultValue = (value: string): string => {
const trimmed = value.trim();
// SQL keywords and function-like keywords that don't need quotes
const keywords = [
'TRUE',
'FALSE',
'NULL',
'CURRENT_TIMESTAMP',
'CURRENT_DATE',
'CURRENT_TIME',
'NOW',
'GETDATE',
'NEWID',
'UUID',
];
if (keywords.includes(trimmed.toUpperCase())) {
return trimmed;
}
// Function calls (contain parentheses) don't need quotes
if (trimmed.includes('(') && trimmed.includes(')')) {
return trimmed;
}
// Numbers don't need quotes
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return trimmed;
}
// Already quoted strings - keep as is
if (
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
) {
return trimmed;
}
// Check if it's a simple identifier (alphanumeric, no spaces) that might be a currency or enum
// These typically don't have spaces and are short (< 10 chars)
if (/^[A-Z][A-Z0-9_]*$/i.test(trimmed) && trimmed.length <= 10) {
return trimmed; // Treat as unquoted identifier (e.g., EUR, USD)
}
// Everything else needs to be quoted and escaped
return `'${trimmed.replace(/'/g, "''")}'`;
};
// Function to simplify verbose data type names
const simplifyDataType = (typeName: string): string => {
@@ -151,10 +201,7 @@ export const exportBaseSQL = ({
// or if we rely on the DBML generator to create Enums separately (as currently done)
// For now, let's assume PostgreSQL-style for demonstration if isDBMLFlow is false.
// If isDBMLFlow is true, we let TableDBML.tsx handle Enum syntax directly.
if (
targetDatabaseType === DatabaseType.POSTGRESQL &&
!isDBMLFlow
) {
if (supportsCustomTypes(targetDatabaseType) && !isDBMLFlow) {
const enumValues = customType.values
.map((v) => `'${v.replace(/'/g, "''")}'`)
.join(', ');
@@ -167,10 +214,7 @@ export const exportBaseSQL = ({
) {
// For PostgreSQL, generate CREATE TYPE ... AS (...)
// This is crucial for composite types to be recognized by the DBML importer
if (
targetDatabaseType === DatabaseType.POSTGRESQL ||
isDBMLFlow
) {
if (supportsCustomTypes(targetDatabaseType) || isDBMLFlow) {
// Assume other DBs might not support this or DBML flow needs it
const compositeFields = customType.fields
.map((f) => `${f.field} ${simplifyDataType(f.type)}`)
@@ -185,13 +229,12 @@ export const exportBaseSQL = ({
(ct.kind === 'enum' &&
ct.values &&
ct.values.length > 0 &&
targetDatabaseType === DatabaseType.POSTGRESQL &&
supportsCustomTypes(targetDatabaseType) &&
!isDBMLFlow) ||
(ct.kind === 'composite' &&
ct.fields &&
ct.fields.length > 0 &&
(targetDatabaseType === DatabaseType.POSTGRESQL ||
isDBMLFlow))
(supportsCustomTypes(targetDatabaseType) || isDBMLFlow))
)
) {
sqlScript += '\n';
@@ -251,7 +294,7 @@ export const exportBaseSQL = ({
if (
customEnumType &&
targetDatabaseType === DatabaseType.POSTGRESQL &&
supportsCustomTypes(targetDatabaseType) &&
!isDBMLFlow
) {
typeName = customEnumType.schema
@@ -294,7 +337,14 @@ export const exportBaseSQL = ({
}
const quotedFieldName = getQuotedFieldName(field.name, isDBMLFlow);
sqlScript += ` ${quotedFieldName} ${typeName}`;
// Quote multi-word type names for DBML flow to prevent @dbml/core parser issues
const quotedTypeName =
isDBMLFlow && typeName.includes(' ')
? `"${typeName}"`
: typeName;
sqlScript += ` ${quotedFieldName} ${quotedTypeName}`;
// Add size for character types
if (
@@ -336,6 +386,11 @@ export const exportBaseSQL = ({
}
}
// Add array suffix if field is an array (after type size and precision)
if (field.isArray) {
sqlScript += '[]';
}
// Handle NOT NULL constraint
if (!field.nullable) {
sqlScript += ' NOT NULL';
@@ -346,9 +401,26 @@ export const exportBaseSQL = ({
sqlScript += ` UNIQUE`;
}
// Handle AUTO INCREMENT - add as a comment for AI to process
// Handle AUTO INCREMENT
if (field.increment) {
sqlScript += ` /* AUTO_INCREMENT */`;
if (isDBMLFlow) {
// For DBML flow, generate proper database-specific syntax
if (
targetDatabaseType === DatabaseType.MYSQL ||
targetDatabaseType === DatabaseType.MARIADB
) {
sqlScript += ` AUTO_INCREMENT`;
} else if (targetDatabaseType === DatabaseType.SQL_SERVER) {
sqlScript += ` IDENTITY(1,1)`;
} else if (targetDatabaseType === DatabaseType.SQLITE) {
// SQLite AUTOINCREMENT only works with INTEGER PRIMARY KEY
// Will be handled when PRIMARY KEY is added
}
// PostgreSQL/CockroachDB: increment attribute added by restoreIncrementAttribute in DBML export
} else {
// For non-DBML flow, add as a comment for AI to process
sqlScript += ` /* AUTO_INCREMENT */`;
}
}
// Handle DEFAULT value
@@ -391,7 +463,9 @@ export const exportBaseSQL = ({
}
}
sqlScript += ` DEFAULT ${fieldDefault}`;
// Format default value with proper quoting
const formattedDefault = formatDefaultValue(fieldDefault);
sqlScript += ` DEFAULT ${formattedDefault}`;
}
}
@@ -399,6 +473,17 @@ export const exportBaseSQL = ({
const pkIndex = table.indexes.find((idx) => idx.isPrimaryKey);
if (field.primaryKey && !hasCompositePrimaryKey && !pkIndex?.name) {
sqlScript += ' PRIMARY KEY';
// For SQLite with DBML flow, add AUTOINCREMENT after PRIMARY KEY
if (
isDBMLFlow &&
field.increment &&
targetDatabaseType === DatabaseType.SQLITE &&
(typeName.toLowerCase() === 'integer' ||
typeName.toLowerCase() === 'int')
) {
sqlScript += ' AUTOINCREMENT';
}
}
// Add a comma after each field except the last one (or before PK constraint)

View File

@@ -10,6 +10,7 @@ import { defaultTableColor } from '@/lib/colors';
import { DatabaseType } from '@/lib/domain/database-type';
import type { DBCustomType } from '@/lib/domain/db-custom-type';
import { DBCustomTypeKind } from '@/lib/domain/db-custom-type';
import { supportsCustomTypes } from '@/lib/domain/database-capabilities';
// Common interfaces for SQL entities
export interface SQLColumn {
@@ -663,7 +664,7 @@ export function convertToChartDBDiagram(
// Ensure integer types are preserved
mappedType = { id: 'integer', name: 'integer' };
} else if (
sourceDatabaseType === DatabaseType.POSTGRESQL &&
supportsCustomTypes(sourceDatabaseType) &&
parserResult.enums &&
parserResult.enums.some(
(e) => e.name.toLowerCase() === column.type.toLowerCase()

View File

@@ -1,6 +1,6 @@
Table "public"."guy_table" {
"id" integer [pk, not null]
"created_at" timestamp [not null]
"created_at" "timestamp without time zone" [not null]
"column3" text
"arrayfield" text[]
"field_5" "character varying"

View File

@@ -1,5 +1,5 @@
Table "public"."orders" {
"order_id" integer [pk, not null]
"order_id" integer [pk, not null, increment]
"customer_id" integer [not null]
"order_date" date [not null, default: `CURRENT_DATE`]
"total_amount" numeric [not null, default: 0]

View File

@@ -0,0 +1,14 @@
Table "users" {
"id" integer [pk, not null, increment]
"username" varchar(100) [unique, not null]
"email" varchar(255) [not null]
}
Table "posts" {
"post_id" bigint [pk, not null, increment]
"user_id" integer [not null]
"title" varchar(200) [not null]
"order_num" integer [not null, increment]
}
Ref "fk_0_fk_posts_users":"users"."id" < "posts"."user_id"

View File

@@ -0,0 +1 @@
{"id":"test_auto_increment","name":"Auto Increment Test (mysql)","createdAt":"2025-01-20T00:00:00.000Z","updatedAt":"2025-01-20T00:00:00.000Z","databaseType":"mysql","tables":[{"id":"table1","name":"users","order":1,"fields":[{"id":"field1","name":"id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":true,"unique":false,"default":"","increment":true,"createdAt":1705708800000},{"id":"field2","name":"username","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":true,"default":"","increment":false,"characterMaximumLength":"100","createdAt":1705708800000},{"id":"field3","name":"email","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"characterMaximumLength":"255","createdAt":1705708800000}],"indexes":[],"x":100,"y":100,"color":"#8eb7ff","isView":false,"createdAt":1705708800000},{"id":"table2","name":"posts","order":2,"fields":[{"id":"field4","name":"post_id","type":{"id":"bigint","name":"bigint"},"nullable":false,"primaryKey":true,"unique":false,"default":"","increment":true,"createdAt":1705708800000},{"id":"field5","name":"user_id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"createdAt":1705708800000},{"id":"field6","name":"title","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"characterMaximumLength":"200","createdAt":1705708800000},{"id":"field7","name":"order_num","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":true,"createdAt":1705708800000}],"indexes":[],"x":300,"y":100,"color":"#8eb7ff","isView":false,"createdAt":1705708800000}],"relationships":[{"id":"rel1","name":"fk_posts_users","sourceTableId":"table2","targetTableId":"table1","sourceFieldId":"field5","targetFieldId":"field1","type":"one_to_many","sourceCardinality":"many","targetCardinality":"one","createdAt":1705708800000}],"dependencies":[],"storageMode":"project","areas":[],"creationMethod":"manual","customTypes":[]}

View File

@@ -0,0 +1,205 @@
import { describe, it, expect } from 'vitest';
import { generateDBMLFromDiagram } from '../dbml-export';
import { DatabaseType } from '@/lib/domain/database-type';
import type { Diagram } from '@/lib/domain/diagram';
import { generateId, generateDiagramId } from '@/lib/utils';
describe('DBML Export - Empty Tables', () => {
it('should filter out tables with no fields', () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'valid_table',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'empty_table',
schema: 'public',
x: 0,
y: 0,
fields: [], // Empty fields array
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'another_valid_table',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'name',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateDBMLFromDiagram(diagram);
// Verify the DBML doesn't contain the empty table
expect(result.inlineDbml).not.toContain('empty_table');
expect(result.standardDbml).not.toContain('empty_table');
// Verify the valid tables are still present
expect(result.inlineDbml).toContain('valid_table');
expect(result.inlineDbml).toContain('another_valid_table');
});
it('should handle diagram with only empty tables', () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'empty_table_1',
schema: 'public',
x: 0,
y: 0,
fields: [],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'empty_table_2',
schema: 'public',
x: 0,
y: 0,
fields: [],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateDBMLFromDiagram(diagram);
// Should not error and should return empty DBML (or just enums if any)
expect(result.inlineDbml).toBeTruthy();
expect(result.standardDbml).toBeTruthy();
expect(result.error).toBeUndefined();
});
it('should filter out table that becomes empty after removing invalid fields', () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'table_with_only_empty_field_names',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: '', // Empty field name - will be filtered
type: { id: 'integer', name: 'integer' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
{
id: generateId(),
name: '', // Empty field name - will be filtered
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'valid_table',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateDBMLFromDiagram(diagram);
// Table with only empty field names should be filtered out
expect(result.inlineDbml).not.toContain(
'table_with_only_empty_field_names'
);
// Valid table should remain
expect(result.inlineDbml).toContain('valid_table');
});
});

View File

@@ -66,4 +66,12 @@ describe('DBML Export cases', () => {
it('should handle case 5 diagram', { timeout: 30000 }, async () => {
testCase('5');
});
it(
'should handle case 6 diagram - auto increment',
{ timeout: 30000 },
async () => {
testCase('6');
}
);
});

View File

@@ -0,0 +1,248 @@
import { describe, it, expect } from 'vitest';
import { generateDBMLFromDiagram } from '../dbml-export';
import { importDBMLToDiagram } from '../../dbml-import/dbml-import';
import { DatabaseType } from '@/lib/domain/database-type';
import type { Diagram } from '@/lib/domain/diagram';
import { generateId, generateDiagramId } from '@/lib/utils';
describe('DBML Export - Timestamp with Time Zone', () => {
it('should preserve "timestamp with time zone" type through export and reimport', async () => {
// Create a diagram with timestamp with time zone field
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'events',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'created_at',
type: {
id: 'timestamp_with_time_zone',
name: 'timestamp with time zone',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'updated_at',
type: {
id: 'timestamp_without_time_zone',
name: 'timestamp without time zone',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
// Export to DBML
const exportResult = generateDBMLFromDiagram(diagram);
// Verify the DBML contains quoted multi-word types
expect(exportResult.inlineDbml).toContain('"timestamp with time zone"');
expect(exportResult.inlineDbml).toContain(
'"timestamp without time zone"'
);
// Reimport the DBML
const reimportedDiagram = await importDBMLToDiagram(
exportResult.inlineDbml,
{
databaseType: DatabaseType.POSTGRESQL,
}
);
// Verify the types are preserved
const table = reimportedDiagram.tables?.find(
(t) => t.name === 'events'
);
expect(table).toBeDefined();
const createdAtField = table?.fields.find(
(f) => f.name === 'created_at'
);
const updatedAtField = table?.fields.find(
(f) => f.name === 'updated_at'
);
expect(createdAtField?.type.name).toBe('timestamp with time zone');
expect(updatedAtField?.type.name).toBe('timestamp without time zone');
});
it('should handle time with time zone types', async () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'schedules',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'start_time',
type: {
id: 'time_with_time_zone',
name: 'time with time zone',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'end_time',
type: {
id: 'time_without_time_zone',
name: 'time without time zone',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const exportResult = generateDBMLFromDiagram(diagram);
expect(exportResult.inlineDbml).toContain('"time with time zone"');
expect(exportResult.inlineDbml).toContain('"time without time zone"');
const reimportedDiagram = await importDBMLToDiagram(
exportResult.inlineDbml,
{
databaseType: DatabaseType.POSTGRESQL,
}
);
const table = reimportedDiagram.tables?.find(
(t) => t.name === 'schedules'
);
const startTimeField = table?.fields.find(
(f) => f.name === 'start_time'
);
const endTimeField = table?.fields.find((f) => f.name === 'end_time');
expect(startTimeField?.type.name).toBe('time with time zone');
expect(endTimeField?.type.name).toBe('time without time zone');
});
it('should handle double precision type', async () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'measurements',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'value',
type: {
id: 'double_precision',
name: 'double precision',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const exportResult = generateDBMLFromDiagram(diagram);
expect(exportResult.inlineDbml).toContain('"double precision"');
const reimportedDiagram = await importDBMLToDiagram(
exportResult.inlineDbml,
{
databaseType: DatabaseType.POSTGRESQL,
}
);
const table = reimportedDiagram.tables?.find(
(t) => t.name === 'measurements'
);
const valueField = table?.fields.find((f) => f.name === 'value');
expect(valueField?.type.name).toBe('double precision');
});
});

View File

@@ -583,6 +583,54 @@ const fixMultilineTableNames = (dbml: string): string => {
);
};
// Restore increment attribute for auto-incrementing fields
const restoreIncrementAttribute = (dbml: string, tables: DBTable[]): string => {
if (!tables || tables.length === 0) return dbml;
let result = dbml;
tables.forEach((table) => {
// Find fields with increment=true
const incrementFields = table.fields.filter((f) => f.increment);
incrementFields.forEach((field) => {
// Build the table identifier pattern
const tableIdentifier = table.schema
? `"${table.schema.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\."${table.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`
: `"${table.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`;
// Escape field name for regex
const escapedFieldName = field.name.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&'
);
// Pattern to match the field line with existing attributes in brackets
// Matches: "field_name" type [existing, attributes]
const fieldPattern = new RegExp(
`(Table ${tableIdentifier} \\{[^}]*?^\\s*"${escapedFieldName}"[^\\[\\n]+)(\\[[^\\]]*\\])`,
'gms'
);
result = result.replace(
fieldPattern,
(match, fieldPart, brackets) => {
// Check if increment already exists
if (brackets.includes('increment')) {
return match;
}
// Add increment to the attributes
const newBrackets = brackets.replace(']', ', increment]');
return fieldPart + newBrackets;
}
);
});
});
return result;
};
// Restore composite primary key names in the DBML
const restoreCompositePKNames = (dbml: string, tables: DBTable[]): string => {
if (!tables || tables.length === 0) return dbml;
@@ -732,9 +780,17 @@ const restoreTableSchemas = (dbml: string, tables: DBTable[]): string => {
return result;
};
// Function to extract only Ref statements from DBML
const extractRelationshipsDbml = (dbml: string): string => {
const lines = dbml.split('\n');
const refLines = lines.filter((line) => line.trim().startsWith('Ref '));
return refLines.join('\n').trim();
};
export interface DBMLExportResult {
standardDbml: string;
inlineDbml: string;
relationshipsDbml: string;
error?: string;
}
@@ -751,31 +807,37 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
};
}) ?? [];
// Remove duplicate tables (consider both schema and table name)
// Filter out empty tables and duplicates in a single pass for performance
const seenTableIdentifiers = new Set<string>();
const uniqueTables = sanitizedTables.filter((table) => {
const tablesWithFields = sanitizedTables.filter((table) => {
// Skip tables with no fields (empty tables cause DBML export to fail)
if (table.fields.length === 0) {
return false;
}
// Create a unique identifier combining schema and table name
const tableIdentifier = table.schema
? `${table.schema}.${table.name}`
: table.name;
// Skip duplicate tables
if (seenTableIdentifiers.has(tableIdentifier)) {
return false; // Skip duplicate
return false;
}
seenTableIdentifiers.add(tableIdentifier);
return true; // Keep unique table
return true; // Keep unique, non-empty table
});
// Create the base filtered diagram structure
const filteredDiagram: Diagram = {
...diagram,
tables: uniqueTables,
tables: tablesWithFields,
relationships:
diagram.relationships?.filter((rel) => {
const sourceTable = uniqueTables.find(
const sourceTable = tablesWithFields.find(
(t) => t.id === rel.sourceTableId
);
const targetTable = uniqueTables.find(
const targetTable = tablesWithFields.find(
(t) => t.id === rel.targetTableId
);
const sourceFieldExists = sourceTable?.fields.some(
@@ -875,10 +937,13 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
);
// Restore schema information that may have been stripped by DBML importer
standard = restoreTableSchemas(standard, uniqueTables);
standard = restoreTableSchemas(standard, tablesWithFields);
// Restore composite primary key names
standard = restoreCompositePKNames(standard, uniqueTables);
standard = restoreCompositePKNames(standard, tablesWithFields);
// Restore increment attribute for auto-incrementing fields
standard = restoreIncrementAttribute(standard, tablesWithFields);
// Prepend Enum DBML to the standard output
if (enumsDBML) {
@@ -923,5 +988,13 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
}
}
return { standardDbml: standard, inlineDbml: inline, error: errorMsg };
// Extract relationships DBML from standard output
const relationshipsDbml = extractRelationshipsDbml(standard);
return {
standardDbml: standard,
inlineDbml: inline,
relationshipsDbml,
error: errorMsg,
};
}

View File

@@ -0,0 +1,317 @@
import { describe, it, expect } from 'vitest';
import { importDBMLToDiagram } from '../dbml-import';
import { generateDBMLFromDiagram } from '../../dbml-export/dbml-export';
import { DatabaseType } from '@/lib/domain/database-type';
describe('DBML Array Fields - Fantasy RPG Theme', () => {
describe('Import - Spell and Magic Arrays', () => {
it('should import spell components as array fields', async () => {
const dbml = `
Table "magic"."spells" {
"id" uuid [pk, not null]
"name" varchar(200) [not null]
"level" integer [not null]
"components" text[] [note: 'Magical components: bat wing, dragon scale, phoenix feather']
"elemental_types" varchar(50)[] [note: 'Elements: fire, water, earth, air']
"mana_cost" integer [not null]
"created_at" timestamp [not null]
Indexes {
(name, level) [unique, name: "unique_spell"]
}
}
`;
const result = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(result.tables).toHaveLength(1);
const table = result.tables![0];
expect(table.name).toBe('spells');
expect(table.schema).toBe('magic');
// Find the array fields
const components = table.fields.find(
(f) => f.name === 'components'
);
const elementalTypes = table.fields.find(
(f) => f.name === 'elemental_types'
);
// Verify they are marked as arrays
expect(components).toBeDefined();
expect(components?.isArray).toBe(true);
expect(components?.type.name).toBe('text');
expect(elementalTypes).toBeDefined();
expect(elementalTypes?.isArray).toBe(true);
expect(elementalTypes?.type.name).toBe('varchar');
expect(elementalTypes?.characterMaximumLength).toBe('50');
// Verify non-array fields don't have isArray set
const idField = table.fields.find((f) => f.name === 'id');
expect(idField?.isArray).toBeUndefined();
});
it('should import hero inventory with various array types', async () => {
const dbml = `
Table "heroes" {
"id" bigint [pk]
"name" varchar(100) [not null]
"abilities" varchar(100)[]
"inventory_slots" integer[]
"skill_levels" decimal(5, 2)[]
"quest_log" text[]
}
`;
const result = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
const table = result.tables![0];
const abilities = table.fields.find((f) => f.name === 'abilities');
expect(abilities?.isArray).toBe(true);
expect(abilities?.type.name).toBe('varchar');
expect(abilities?.characterMaximumLength).toBe('100');
const inventorySlots = table.fields.find(
(f) => f.name === 'inventory_slots'
);
expect(inventorySlots?.isArray).toBe(true);
expect(inventorySlots?.type.name).toBe('integer');
const skillLevels = table.fields.find(
(f) => f.name === 'skill_levels'
);
expect(skillLevels?.isArray).toBe(true);
expect(skillLevels?.type.name).toBe('decimal');
expect(skillLevels?.precision).toBe(5);
expect(skillLevels?.scale).toBe(2);
const questLog = table.fields.find((f) => f.name === 'quest_log');
expect(questLog?.isArray).toBe(true);
expect(questLog?.type.name).toBe('text');
});
it('should handle mixed array and non-array fields in creature table', async () => {
const dbml = `
Table "bestiary"."creatures" {
"id" uuid [pk]
"species_name" varchar(100) [not null]
"habitats" varchar(50)[]
"danger_level" integer [not null]
"resistances" varchar(50)[]
"is_tameable" boolean [not null]
}
`;
const result = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
const table = result.tables![0];
// Non-array fields
const id = table.fields.find((f) => f.name === 'id');
expect(id?.isArray).toBeUndefined();
const speciesName = table.fields.find(
(f) => f.name === 'species_name'
);
expect(speciesName?.isArray).toBeUndefined();
const dangerLevel = table.fields.find(
(f) => f.name === 'danger_level'
);
expect(dangerLevel?.isArray).toBeUndefined();
// Array fields
const habitats = table.fields.find((f) => f.name === 'habitats');
expect(habitats?.isArray).toBe(true);
const resistances = table.fields.find(
(f) => f.name === 'resistances'
);
expect(resistances?.isArray).toBe(true);
});
});
describe('Round-trip - Quest and Adventure Arrays', () => {
it('should preserve quest rewards array through export and re-import', async () => {
const originalDbml = `
Table "adventures"."quests" {
"id" uuid [pk, not null]
"title" varchar(200) [not null]
"difficulty" varchar(20) [not null]
"reward_items" text[] [note: 'Legendary sword, enchanted armor, healing potion']
"required_skills" varchar(100)[]
"experience_points" integer [not null]
"gold_reward" decimal(10, 2) [not null]
"created_at" timestamp [not null]
Indexes {
(title, difficulty) [unique, name: "unique_quest"]
}
}
`;
// Import the DBML
const diagram = await importDBMLToDiagram(originalDbml, {
databaseType: DatabaseType.POSTGRESQL,
});
// Verify array fields were imported correctly
const table = diagram.tables![0];
const rewardItems = table.fields.find(
(f) => f.name === 'reward_items'
);
const requiredSkills = table.fields.find(
(f) => f.name === 'required_skills'
);
expect(rewardItems?.isArray).toBe(true);
expect(requiredSkills?.isArray).toBe(true);
// Export back to DBML
const { standardDbml: exportedDbml } =
generateDBMLFromDiagram(diagram);
// Verify the exported DBML contains array syntax
expect(exportedDbml).toContain('text[]');
expect(exportedDbml).toContain('"reward_items" text[]');
expect(exportedDbml).toContain('"required_skills" varchar(100)[]');
// Re-import the exported DBML
const reimportedDiagram = await importDBMLToDiagram(exportedDbml, {
databaseType: DatabaseType.POSTGRESQL,
});
// Verify array fields are still marked as arrays
const reimportedTable = reimportedDiagram.tables![0];
const reimportedRewards = reimportedTable.fields.find(
(f) => f.name === 'reward_items'
);
const reimportedSkills = reimportedTable.fields.find(
(f) => f.name === 'required_skills'
);
expect(reimportedRewards?.isArray).toBe(true);
expect(reimportedSkills?.isArray).toBe(true);
});
it('should handle guild members with different array types in round-trip', async () => {
const originalDbml = `
Table "guilds"."members" {
"id" uuid [pk]
"name" varchar(100) [not null]
"class_specializations" varchar(50)[]
"completed_quest_ids" integer[]
"skill_ratings" decimal(3, 1)[]
"titles_earned" text[]
}
`;
// Import
const diagram = await importDBMLToDiagram(originalDbml, {
databaseType: DatabaseType.POSTGRESQL,
});
// Export
const { standardDbml: exportedDbml } =
generateDBMLFromDiagram(diagram);
// Verify exported DBML has correct array syntax with types
expect(exportedDbml).toContain('varchar(50)[]');
expect(exportedDbml).toContain('integer[]');
expect(exportedDbml).toContain('decimal(3,1)[]');
expect(exportedDbml).toContain('text[]');
// Re-import
const reimportedDiagram = await importDBMLToDiagram(exportedDbml, {
databaseType: DatabaseType.POSTGRESQL,
});
const table = reimportedDiagram.tables![0];
const classSpecs = table.fields.find(
(f) => f.name === 'class_specializations'
);
expect(classSpecs?.isArray).toBe(true);
expect(classSpecs?.characterMaximumLength).toBe('50');
const questIds = table.fields.find(
(f) => f.name === 'completed_quest_ids'
);
expect(questIds?.isArray).toBe(true);
const skillRatings = table.fields.find(
(f) => f.name === 'skill_ratings'
);
expect(skillRatings?.isArray).toBe(true);
expect(skillRatings?.precision).toBe(3);
expect(skillRatings?.scale).toBe(1);
const titles = table.fields.find((f) => f.name === 'titles_earned');
expect(titles?.isArray).toBe(true);
});
it('should preserve dungeon loot tables with mixed array and non-array fields', async () => {
const originalDbml = `
Table "dungeons"."loot_tables" {
"id" bigint [pk]
"dungeon_name" varchar(150) [not null]
"boss_name" varchar(100)
"common_drops" text[]
"rare_drops" text[]
"legendary_drops" text[]
"gold_range_min" integer [not null]
"gold_range_max" integer [not null]
"drop_rates" decimal(5, 2)[]
}
`;
// Import, export, and re-import
const diagram = await importDBMLToDiagram(originalDbml, {
databaseType: DatabaseType.POSTGRESQL,
});
const { standardDbml: exportedDbml } =
generateDBMLFromDiagram(diagram);
const reimportedDiagram = await importDBMLToDiagram(exportedDbml, {
databaseType: DatabaseType.POSTGRESQL,
});
const table = reimportedDiagram.tables![0];
// Verify non-array fields
expect(
table.fields.find((f) => f.name === 'id')?.isArray
).toBeUndefined();
expect(
table.fields.find((f) => f.name === 'dungeon_name')?.isArray
).toBeUndefined();
expect(
table.fields.find((f) => f.name === 'gold_range_min')?.isArray
).toBeUndefined();
// Verify array fields
expect(
table.fields.find((f) => f.name === 'common_drops')?.isArray
).toBe(true);
expect(
table.fields.find((f) => f.name === 'rare_drops')?.isArray
).toBe(true);
expect(
table.fields.find((f) => f.name === 'legendary_drops')?.isArray
).toBe(true);
expect(
table.fields.find((f) => f.name === 'drop_rates')?.isArray
).toBe(true);
});
});
});

View File

@@ -295,4 +295,132 @@ describe('DBML Import cases', () => {
it('should handle case 2 - tables with relationships', async () => {
await testDBMLImportCase('2');
});
it('should handle table with default values', async () => {
const dbmlContent = `Table "public"."products" {
"id" bigint [pk, not null]
"name" varchar(255) [not null]
"price" decimal(10,2) [not null, default: 0]
"is_active" boolean [not null, default: true]
"status" varchar(50) [not null, default: "deprecated"]
"description" varchar(100) [default: \`complex "value" with quotes\`]
"created_at" timestamp [not null, default: "now()"]
Indexes {
(name) [name: "idx_products_name"]
}
}`;
const result = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(result.tables).toHaveLength(1);
const table = result.tables![0];
expect(table.name).toBe('products');
expect(table.fields).toHaveLength(7);
// Check numeric default (0)
const priceField = table.fields.find((f) => f.name === 'price');
expect(priceField?.default).toBe('0');
// Check boolean default (true)
const isActiveField = table.fields.find((f) => f.name === 'is_active');
expect(isActiveField?.default).toBe('true');
// Check string default with all quotes removed
const statusField = table.fields.find((f) => f.name === 'status');
expect(statusField?.default).toBe('deprecated');
// Check backtick string - all quotes removed
const descField = table.fields.find((f) => f.name === 'description');
expect(descField?.default).toBe('complex value with quotes');
// Check function default with all quotes removed
const createdAtField = table.fields.find(
(f) => f.name === 'created_at'
);
expect(createdAtField?.default).toBe('now()');
});
it('should handle auto-increment fields correctly', async () => {
const dbmlContent = `Table "public"."table_1" {
"id" integer [pk, not null, increment]
"field_2" bigint [increment]
"field_3" serial [increment]
"field_4" varchar(100) [not null]
}`;
const result = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(result.tables).toHaveLength(1);
const table = result.tables![0];
expect(table.name).toBe('table_1');
expect(table.fields).toHaveLength(4);
// field with [pk, not null, increment] - should be not null and increment
const idField = table.fields.find((f) => f.name === 'id');
expect(idField?.increment).toBe(true);
expect(idField?.nullable).toBe(false);
expect(idField?.primaryKey).toBe(true);
// field with [increment] only - should be not null and increment
// (auto-increment requires NOT NULL even if not explicitly stated)
const field2 = table.fields.find((f) => f.name === 'field_2');
expect(field2?.increment).toBe(true);
expect(field2?.nullable).toBe(false); // CRITICAL: must be false!
// SERIAL type with [increment] - should be not null and increment
const field3 = table.fields.find((f) => f.name === 'field_3');
expect(field3?.increment).toBe(true);
expect(field3?.nullable).toBe(false);
expect(field3?.type?.name).toBe('serial');
// Regular field with [not null] - should be not null, no increment
const field4 = table.fields.find((f) => f.name === 'field_4');
expect(field4?.increment).toBeUndefined();
expect(field4?.nullable).toBe(false);
});
it('should handle SERIAL types without increment attribute', async () => {
const dbmlContent = `Table "public"."test_table" {
"id" serial [pk]
"counter" bigserial
"small_counter" smallserial
"regular" integer
}`;
const result = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(result.tables).toHaveLength(1);
const table = result.tables![0];
expect(table.fields).toHaveLength(4);
// SERIAL type without [increment] - should STILL be not null (type requires it)
const idField = table.fields.find((f) => f.name === 'id');
expect(idField?.type?.name).toBe('serial');
expect(idField?.nullable).toBe(false); // CRITICAL: Type requires NOT NULL
expect(idField?.primaryKey).toBe(true);
// BIGSERIAL without [increment] - should be not null
const counterField = table.fields.find((f) => f.name === 'counter');
expect(counterField?.type?.name).toBe('bigserial');
expect(counterField?.nullable).toBe(false); // CRITICAL: Type requires NOT NULL
// SMALLSERIAL without [increment] - should be not null
const smallCounterField = table.fields.find(
(f) => f.name === 'small_counter'
);
expect(smallCounterField?.type?.name).toBe('smallserial');
expect(smallCounterField?.nullable).toBe(false); // CRITICAL: Type requires NOT NULL
// Regular INTEGER - should be nullable by default
const regularField = table.fields.find((f) => f.name === 'regular');
expect(regularField?.type?.name).toBe('integer');
expect(regularField?.nullable).toBe(true); // No NOT NULL constraint
});
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { importDBMLToDiagram } from '../dbml-import';
import { DBCustomTypeKind } from '@/lib/domain/db-custom-type';
import { DatabaseType } from '@/lib/domain/database-type';
describe('DBML Import - Fantasy Examples', () => {
describe('Magical Academy System', () => {
@@ -149,7 +150,9 @@ Table ranks {
max_spell_level integer [not null]
}`;
const diagram = await importDBMLToDiagram(magicalAcademyDBML);
const diagram = await importDBMLToDiagram(magicalAcademyDBML, {
databaseType: DatabaseType.POSTGRESQL,
});
// Verify tables
expect(diagram.tables).toHaveLength(8);
@@ -366,7 +369,9 @@ Note marketplace_note {
'This marketplace handles both standard purchases and barter trades'
}`;
const diagram = await importDBMLToDiagram(marketplaceDBML);
const diagram = await importDBMLToDiagram(marketplaceDBML, {
databaseType: DatabaseType.POSTGRESQL,
});
// Verify tables
expect(diagram.tables).toHaveLength(7);
@@ -567,7 +572,9 @@ Note quest_system_note {
'Quest difficulty and status use enums that will be converted to varchar'
}`;
const diagram = await importDBMLToDiagram(questSystemDBML);
const diagram = await importDBMLToDiagram(questSystemDBML, {
databaseType: DatabaseType.POSTGRESQL,
});
// Verify tables
expect(diagram.tables).toHaveLength(7);
@@ -657,7 +664,9 @@ Table projects {
priority enum // inline enum without values - will be converted to varchar
}`;
const diagram = await importDBMLToDiagram(dbmlWithEnums);
const diagram = await importDBMLToDiagram(dbmlWithEnums, {
databaseType: DatabaseType.POSTGRESQL,
});
// Verify customTypes are created for enums
expect(diagram.customTypes).toBeDefined();
@@ -744,7 +753,9 @@ Table orders {
status order_status [not null]
}`;
const diagram = await importDBMLToDiagram(dbmlWithEnumNotes);
const diagram = await importDBMLToDiagram(dbmlWithEnumNotes, {
databaseType: DatabaseType.POSTGRESQL,
});
// Verify enum is created
expect(diagram.customTypes).toHaveLength(1);
@@ -788,7 +799,9 @@ Table admin.users {
status admin.status
}`;
const diagram = await importDBMLToDiagram(dbmlWithSameEnumNames);
const diagram = await importDBMLToDiagram(dbmlWithSameEnumNames, {
databaseType: DatabaseType.POSTGRESQL,
});
// Verify both enums are created
expect(diagram.customTypes).toHaveLength(2);
@@ -891,7 +904,9 @@ Note dragon_note {
'Dragons are very protective of their hoards!'
}`;
const diagram = await importDBMLToDiagram(edgeCaseDBML);
const diagram = await importDBMLToDiagram(edgeCaseDBML, {
databaseType: DatabaseType.POSTGRESQL,
});
// Verify preprocessing worked
expect(diagram.tables).toHaveLength(2);
@@ -956,7 +971,9 @@ Note dragon_note {
it('should handle empty DBML gracefully', async () => {
const emptyDBML = '';
const diagram = await importDBMLToDiagram(emptyDBML);
const diagram = await importDBMLToDiagram(emptyDBML, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.tables).toHaveLength(0);
expect(diagram.relationships).toHaveLength(0);
@@ -969,7 +986,9 @@ Note dragon_note {
/* Multi-line
comment */
`;
const diagram = await importDBMLToDiagram(commentOnlyDBML);
const diagram = await importDBMLToDiagram(commentOnlyDBML, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.tables).toHaveLength(0);
expect(diagram.relationships).toHaveLength(0);
@@ -980,7 +999,9 @@ Note dragon_note {
Table empty_table {
id int
}`;
const diagram = await importDBMLToDiagram(minimalDBML);
const diagram = await importDBMLToDiagram(minimalDBML, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.tables).toHaveLength(1);
expect(diagram.tables?.[0]?.fields).toHaveLength(1);
@@ -996,7 +1017,9 @@ Table "aa"."users" {
Table "bb"."users" {
id integer [primary key]
}`;
const diagram = await importDBMLToDiagram(dbml);
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.tables).toHaveLength(2);
@@ -1071,7 +1094,9 @@ Table "public_3"."comments" {
id [unique, name: "public_3_index_1"]
}
}`;
const diagram = await importDBMLToDiagram(dbml);
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
// Verify tables
expect(diagram.tables).toHaveLength(3);
@@ -1256,7 +1281,9 @@ Table products {
Note: 'This table stores product information'
}`;
const diagram = await importDBMLToDiagram(dbmlWithTableNote);
const diagram = await importDBMLToDiagram(dbmlWithTableNote, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.tables).toHaveLength(1);
const productsTable = diagram.tables?.[0];
@@ -1273,7 +1300,9 @@ Table orders {
total numeric(10,2) [note: 'Order total including tax']
}`;
const diagram = await importDBMLToDiagram(dbmlWithFieldNote);
const diagram = await importDBMLToDiagram(dbmlWithFieldNote, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.tables).toHaveLength(1);
const ordersTable = diagram.tables?.[0];

View File

@@ -5,6 +5,7 @@ import {
importDBMLToDiagram,
} from '../dbml-import';
import { Parser } from '@dbml/core';
import { DatabaseType } from '@/lib/domain/database-type';
describe('DBML Import', () => {
describe('preprocessDBML', () => {
@@ -22,7 +23,7 @@ TableGroup "Test Group" [color: #CA4243] {
Table posts {
id int
}`;
const result = preprocessDBML(dbml);
const { content: result } = preprocessDBML(dbml);
expect(result).not.toContain('TableGroup');
expect(result).toContain('Table users');
expect(result).toContain('Table posts');
@@ -37,20 +38,20 @@ Table users {
Note note_test {
'This is a note'
}`;
const result = preprocessDBML(dbml);
const { content: result } = preprocessDBML(dbml);
expect(result).not.toContain('Note');
expect(result).toContain('Table users');
});
it('should convert array types to text', () => {
it('should remove array syntax while preserving base type', () => {
const dbml = `
Table users {
tags text[]
domains varchar[]
}`;
const result = preprocessDBML(dbml);
const { content: result } = preprocessDBML(dbml);
expect(result).toContain('tags text');
expect(result).toContain('domains text');
expect(result).toContain('domains varchar');
expect(result).not.toContain('[]');
});
@@ -60,7 +61,7 @@ Table users {
status enum
verification_type enum // comment here
}`;
const result = preprocessDBML(dbml);
const { content: result } = preprocessDBML(dbml);
expect(result).toContain('status varchar');
expect(result).toContain('verification_type varchar');
expect(result).not.toContain('enum');
@@ -71,7 +72,7 @@ Table users {
Table users [headercolor: #24BAB1] {
id int
}`;
const result = preprocessDBML(dbml);
const { content: result } = preprocessDBML(dbml);
expect(result).toContain('Table users {');
expect(result).not.toContain('headercolor');
});
@@ -105,7 +106,9 @@ Note note_test {
'This is a test note'
}`;
const diagram = await importDBMLToDiagram(complexDBML);
const diagram = await importDBMLToDiagram(complexDBML, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.tables).toHaveLength(2);
expect(diagram.relationships).toHaveLength(1);
@@ -149,7 +152,7 @@ Note note_1750185617764 {
}`;
// Test that preprocessing handles all issues
const preprocessed = preprocessDBML(problematicDBML);
const { content: preprocessed } = preprocessDBML(problematicDBML);
const sanitized = sanitizeDBML(preprocessed);
// Should not throw

View File

@@ -38,7 +38,9 @@ Note test_note {
'This is a test note'
}`;
const diagram = await importDBMLToDiagram(dbmlContent);
const diagram = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.POSTGRESQL,
});
// Verify basic structure
expect(diagram).toBeDefined();
@@ -96,7 +98,9 @@ Table products [headercolor: #FF0000] {
Ref: products.id < users.favorite_product_id`;
const diagram = await importDBMLToDiagram(dbmlContent);
const diagram = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.tables).toHaveLength(2);
@@ -119,12 +123,16 @@ Ref: products.id < users.favorite_product_id`;
it('should handle empty or invalid DBML gracefully', async () => {
// Empty DBML
const emptyDiagram = await importDBMLToDiagram('');
const emptyDiagram = await importDBMLToDiagram('', {
databaseType: DatabaseType.POSTGRESQL,
});
expect(emptyDiagram.tables).toHaveLength(0);
expect(emptyDiagram.relationships).toHaveLength(0);
// Only comments
const commentDiagram = await importDBMLToDiagram('// Just a comment');
const commentDiagram = await importDBMLToDiagram('// Just a comment', {
databaseType: DatabaseType.POSTGRESQL,
});
expect(commentDiagram.tables).toHaveLength(0);
expect(commentDiagram.relationships).toHaveLength(0);
});
@@ -133,7 +141,9 @@ Ref: products.id < users.favorite_product_id`;
const dbmlContent = `Table test {
id int [pk]
}`;
const diagram = await importDBMLToDiagram(dbmlContent);
const diagram = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.GENERIC,
});
// Default values
expect(diagram.name).toBe('DBML Import');

View File

@@ -1,4 +1,6 @@
import type { CompilerError } from '@dbml/core/types/parse/error';
import type { DatabaseType } from '@/lib/domain/database-type';
import { databaseSupportsArrays } from '@/lib/domain/database-capabilities';
export interface DBMLError {
message: string;
@@ -6,8 +8,59 @@ export interface DBMLError {
column: number;
}
export class DBMLValidationError extends Error {
public readonly dbmlError: DBMLError;
constructor(message: string, line: number, column: number = 1) {
super(message);
this.name = 'DBMLValidationError';
this.dbmlError = { message, line, column };
}
}
export const getPositionFromIndex = (
content: string,
matchIndex: number
): { line: number; column: number } => {
const lines = content.substring(0, matchIndex).split('\n');
return {
line: lines.length,
column: lines[lines.length - 1].length + 1,
};
};
export const validateArrayTypesForDatabase = (
content: string,
databaseType: DatabaseType
): void => {
// Only validate if database doesn't support arrays
if (databaseSupportsArrays(databaseType)) {
return;
}
const arrayFieldPattern = /"?(\w+)"?\s+(\w+(?:\(\d+(?:,\s*\d+)?\))?)\[\]/g;
const matches = [...content.matchAll(arrayFieldPattern)];
for (const match of matches) {
const fieldName = match[1];
const dataType = match[2];
const { line, column } = getPositionFromIndex(content, match.index!);
throw new DBMLValidationError(
`Array types are not supported for ${databaseType} database. Field "${fieldName}" has array type "${dataType}[]" which is not allowed.`,
line,
column
);
}
};
export function parseDBMLError(error: unknown): DBMLError | null {
try {
// Check for our custom DBMLValidationError
if (error instanceof DBMLValidationError) {
return error.dbmlError;
}
if (typeof error === 'string') {
const parsed = JSON.parse(error);
if (parsed.diags?.[0]) {

View File

@@ -5,7 +5,10 @@ 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 { DataTypeData } from '@/lib/data/data-types/data-types';
import { findDataTypeDataById } from '@/lib/data/data-types/data-types';
import {
findDataTypeDataById,
requiresNotNull,
} from '@/lib/data/data-types/data-types';
import { defaultTableColor } from '@/lib/colors';
import { DatabaseType } from '@/lib/domain/database-type';
import type Field from '@dbml/core/types/model_structure/field';
@@ -14,13 +17,21 @@ import {
DBCustomTypeKind,
type DBCustomType,
} from '@/lib/domain/db-custom-type';
import { validateArrayTypesForDatabase } from './dbml-import-error';
export const defaultDBMLDiagramName = 'DBML Import';
// Preprocess DBML to handle unsupported features
export const preprocessDBML = (content: string): string => {
interface PreprocessDBMLResult {
content: string;
arrayFields: Map<string, Set<string>>;
}
export const preprocessDBML = (content: string): PreprocessDBMLResult => {
let processed = content;
// Track array fields found during preprocessing
const arrayFields = new Map<string, Set<string>>();
// Remove TableGroup blocks (not supported by parser)
processed = processed.replace(/TableGroup\s+[^{]*\{[^}]*\}/gs, '');
@@ -30,8 +41,37 @@ export const preprocessDBML = (content: string): string => {
// 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');
// Handle array types by tracking them and converting syntax for DBML parser
// Note: DBML doesn't officially support array syntax, so we convert type[] to type
// but track which fields should be arrays
// First, find all array field declarations and track them
const tablePattern =
/Table\s+(?:"([^"]+)"\.)?(?:"([^"]+)"|(\w+))\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/gs;
let match;
while ((match = tablePattern.exec(content)) !== null) {
const schema = match[1] || '';
const tableName = match[2] || match[3];
const tableBody = match[4];
const fullTableName = schema ? `${schema}.${tableName}` : tableName;
// Find array field declarations within this table
const fieldPattern = /"?(\w+)"?\s+(\w+(?:\([^)]+\))?)\[\]/g;
let fieldMatch;
while ((fieldMatch = fieldPattern.exec(tableBody)) !== null) {
const fieldName = fieldMatch[1];
if (!arrayFields.has(fullTableName)) {
arrayFields.set(fullTableName, new Set());
}
arrayFields.get(fullTableName)!.add(fieldName);
}
}
// Now convert array syntax for DBML parser (keep the base type, remove [])
processed = processed.replace(/(\w+(?:\(\d+(?:,\s*\d+)?\))?)\[\]/g, '$1');
// Handle inline enum types without values by converting to varchar
processed = processed.replace(
@@ -46,7 +86,7 @@ export const preprocessDBML = (content: string): string => {
'Table $1 {'
);
return processed;
return { content: processed, arrayFields };
};
// Simple function to replace Spanish special characters
@@ -85,10 +125,12 @@ interface DBMLField {
pk?: boolean;
not_null?: boolean;
increment?: boolean;
isArray?: boolean;
characterMaximumLength?: string | null;
precision?: number | null;
scale?: number | null;
note?: string | { value: string } | null;
default?: string | null;
}
interface DBMLIndexColumn {
@@ -189,8 +231,8 @@ const determineCardinality = (
export const importDBMLToDiagram = async (
dbmlContent: string,
options?: {
databaseType?: DatabaseType;
options: {
databaseType: DatabaseType;
}
): Promise<Diagram> => {
try {
@@ -207,9 +249,13 @@ export const importDBMLToDiagram = async (
};
}
// Validate array types BEFORE preprocessing (preprocessing removes [])
validateArrayTypesForDatabase(dbmlContent, options.databaseType);
const parser = new Parser();
// Preprocess and sanitize DBML content
const preprocessedContent = preprocessDBML(dbmlContent);
const { content: preprocessedContent, arrayFields } =
preprocessDBML(dbmlContent);
const sanitizedContent = sanitizeDBML(preprocessedContent);
// Handle content that becomes empty after preprocessing
@@ -334,6 +380,33 @@ export const importDBMLToDiagram = async (
schema: schemaName,
note: table.note,
fields: table.fields.map((field): DBMLField => {
// Extract default value and remove all quotes
let defaultValue: string | undefined;
if (
field.dbdefault !== undefined &&
field.dbdefault !== null
) {
const rawDefault = String(
field.dbdefault.value
);
defaultValue = rawDefault.replace(/['"`]/g, '');
}
// Check if this field should be an array
const fullTableName = schemaName
? `${schemaName}.${table.name}`
: table.name;
let isArray = arrayFields
.get(fullTableName)
?.has(field.name);
if (!isArray && schemaName) {
isArray = arrayFields
.get(table.name)
?.has(field.name);
}
return {
name: field.name,
type: field.type,
@@ -341,7 +414,9 @@ export const importDBMLToDiagram = async (
pk: field.pk,
not_null: field.not_null,
increment: field.increment,
isArray: isArray || undefined,
note: field.note,
default: defaultValue,
...getFieldExtraAttributes(field, allEnums),
} satisfies DBMLField;
}),
@@ -480,14 +555,20 @@ export const importDBMLToDiagram = async (
...options,
enums: extractedData.enums,
}),
nullable: !field.not_null,
nullable:
field.increment || requiresNotNull(field.type.type_name)
? false
: !field.not_null,
primaryKey: field.pk || false,
unique: field.unique || field.pk || false, // Primary keys are always unique
createdAt: Date.now(),
characterMaximumLength: field.characterMaximumLength,
precision: field.precision,
scale: field.scale,
...(field.increment ? { increment: field.increment } : {}),
...(field.isArray ? { isArray: field.isArray } : {}),
...(fieldComment ? { comments: fieldComment } : {}),
...(field.default ? { default: field.default } : {}),
};
});

View File

@@ -1,10 +1,19 @@
import { Parser } from '@dbml/core';
import { preprocessDBML, sanitizeDBML } from './dbml-import';
import type { DBMLError } from './dbml-import-error';
import { parseDBMLError } from './dbml-import-error';
import {
parseDBMLError,
validateArrayTypesForDatabase,
} from './dbml-import-error';
import type { DatabaseType } from '@/lib/domain/database-type';
export const verifyDBML = (
content: string
content: string,
{
databaseType,
}: {
databaseType: DatabaseType;
}
):
| {
hasError: true;
@@ -16,8 +25,12 @@ export const verifyDBML = (
hasError: false;
} => {
try {
const preprocessedContent = preprocessDBML(content);
// Validate array types BEFORE preprocessing (preprocessing removes [])
validateArrayTypesForDatabase(content, databaseType);
const { content: preprocessedContent } = preprocessDBML(content);
const sanitizedContent = sanitizeDBML(preprocessedContent);
const parser = new Parser();
parser.parse(sanitizedContent, 'dbmlv2');
} catch (e) {

View File

@@ -0,0 +1,57 @@
import { DatabaseType } from './database-type';
export interface DatabaseCapabilities {
supportsArrays?: boolean;
supportsCustomTypes?: boolean;
supportsSchemas?: boolean;
supportsComments?: boolean;
}
export const DATABASE_CAPABILITIES: Record<DatabaseType, DatabaseCapabilities> =
{
[DatabaseType.POSTGRESQL]: {
supportsArrays: true,
supportsCustomTypes: true,
supportsSchemas: true,
supportsComments: true,
},
[DatabaseType.COCKROACHDB]: {
supportsArrays: true,
supportsSchemas: true,
supportsComments: true,
},
[DatabaseType.MYSQL]: {},
[DatabaseType.MARIADB]: {},
[DatabaseType.SQL_SERVER]: {
supportsSchemas: true,
},
[DatabaseType.SQLITE]: {},
[DatabaseType.CLICKHOUSE]: {
supportsSchemas: true,
},
[DatabaseType.ORACLE]: {
supportsSchemas: true,
supportsComments: true,
},
[DatabaseType.GENERIC]: {},
};
export const getDatabaseCapabilities = (
databaseType: DatabaseType
): DatabaseCapabilities => {
return DATABASE_CAPABILITIES[databaseType];
};
export const databaseSupportsArrays = (databaseType: DatabaseType): boolean => {
return getDatabaseCapabilities(databaseType).supportsArrays ?? false;
};
export const databaseTypesWithCommentSupport: DatabaseType[] = Object.keys(
DATABASE_CAPABILITIES
).filter(
(dbType) => DATABASE_CAPABILITIES[dbType as DatabaseType].supportsComments
) as DatabaseType[];
export const supportsCustomTypes = (databaseType: DatabaseType): boolean => {
return getDatabaseCapabilities(databaseType).supportsCustomTypes ?? false;
};

View File

@@ -9,9 +9,3 @@ export enum DatabaseType {
COCKROACHDB = 'cockroachdb',
ORACLE = 'oracle',
}
export const databaseTypesWithCommentSupport: DatabaseType[] = [
DatabaseType.POSTGRESQL,
DatabaseType.COCKROACHDB,
DatabaseType.ORACLE,
];

View File

@@ -2,9 +2,10 @@ import { z } from 'zod';
import {
dataTypeSchema,
findDataTypeDataById,
supportsArrayDataType,
type DataType,
} from '../data/data-types/data-types';
import type { DatabaseType } from './database-type';
import { DatabaseType } from './database-type';
export interface DBField {
id: string;
@@ -14,6 +15,7 @@ export interface DBField {
unique: boolean;
nullable: boolean;
increment?: boolean | null;
isArray?: boolean | null;
createdAt: number;
characterMaximumLength?: string | null;
precision?: number | null;
@@ -31,6 +33,7 @@ export const dbFieldSchema: z.ZodType<DBField> = z.object({
unique: z.boolean(),
nullable: z.boolean(),
increment: z.boolean().or(z.null()).optional(),
isArray: z.boolean().or(z.null()).optional(),
createdAt: z.number(),
characterMaximumLength: z.string().or(z.null()).optional(),
precision: z.number().or(z.null()).optional(),
@@ -52,11 +55,26 @@ export const generateDBFieldSuffix = (
typeId?: string;
} = {}
): string => {
let suffix = '';
if (databaseType && forceExtended && typeId) {
return generateExtendedSuffix(field, databaseType, typeId);
suffix = generateExtendedSuffix(field, databaseType, typeId);
} else {
suffix = generateStandardSuffix(field);
}
return generateStandardSuffix(field);
// Add array notation if field is an array
if (
field.isArray &&
supportsArrayDataType(
typeId ?? field.type.id,
databaseType ?? DatabaseType.GENERIC
)
) {
suffix += '[]';
}
return suffix;
};
const generateExtendedSuffix = (

View File

@@ -1,4 +1,5 @@
import { DatabaseType } from './database-type';
import { DATABASE_CAPABILITIES } from './database-capabilities';
import type { DatabaseType } from './database-type';
export interface DBSchema {
id: string;
@@ -18,10 +19,8 @@ export const schemaNameToDomainSchemaName = (
? undefined
: schema?.trim();
export const databasesWithSchemas: DatabaseType[] = [
DatabaseType.POSTGRESQL,
DatabaseType.SQL_SERVER,
DatabaseType.CLICKHOUSE,
DatabaseType.COCKROACHDB,
DatabaseType.ORACLE,
];
export const databasesWithSchemas: DatabaseType[] = Object.keys(
DATABASE_CAPABILITIES
).filter(
(dbType) => DATABASE_CAPABILITIES[dbType as DatabaseType].supportsSchemas
) as DatabaseType[];

View File

@@ -28,6 +28,16 @@ export function getDiffMapKey({
: `${diffObject}-${objectId}`;
}
const isOneOfDefined = (
...values: (string | number | boolean | undefined | null)[]
): boolean => {
return values.some((value) => value !== undefined && value !== null);
};
const normalizeBoolean = (value: boolean | undefined | null): boolean => {
return value === true;
};
export interface GenerateDiffOptions {
includeTables?: boolean;
includeFields?: boolean;
@@ -552,6 +562,8 @@ function compareFieldProperties({
'characterMaximumLength',
'scale',
'precision',
'increment',
'isArray',
];
const changedAttributes: FieldDiffAttribute[] = [];
@@ -620,6 +632,24 @@ function compareFieldProperties({
changedAttributes.push('precision');
}
if (
attributesToCheck.includes('increment') &&
isOneOfDefined(newField.increment, oldField.increment) &&
normalizeBoolean(oldField.increment) !==
normalizeBoolean(newField.increment)
) {
changedAttributes.push('increment');
}
if (
attributesToCheck.includes('isArray') &&
isOneOfDefined(newField.isArray, oldField.isArray) &&
normalizeBoolean(oldField.isArray) !==
normalizeBoolean(newField.isArray)
) {
changedAttributes.push('isArray');
}
if (changedAttributes.length > 0) {
for (const attribute of changedAttributes) {
diffMap.set(

View File

@@ -15,7 +15,9 @@ export type FieldDiffAttribute =
| 'comments'
| 'characterMaximumLength'
| 'precision'
| 'scale';
| 'scale'
| 'increment'
| 'isArray';
export const fieldDiffAttributeSchema: z.ZodType<FieldDiffAttribute> = z.union([
z.literal('name'),

View File

@@ -16,6 +16,7 @@ import { Check, GripVertical, Pencil } from 'lucide-react';
import { Button } from '@/components/button/button';
import { useLayout } from '@/hooks/use-layout';
import { AreaNodeContextMenu } from './area-node-context-menu';
import { useCanvas } from '@/hooks/use-canvas';
export type AreaNodeType = Node<
{
@@ -57,6 +58,8 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
useKeyPressEvent('Enter', editAreaName);
useKeyPressEvent('Escape', abortEdit);
const { setEditTableModeTable } = useCanvas();
const enterEditMode = (e: React.MouseEvent) => {
e.stopPropagation();
setEditMode(true);
@@ -77,6 +80,7 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
borderColor: selected ? undefined : area.color,
}}
onClick={(e) => {
setEditTableModeTable(null);
if (e.detail === 2) {
openAreaInEditor();
}

View File

@@ -13,92 +13,96 @@ import { useTranslation } from 'react-i18next';
import { Table, Workflow, Group, View } 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';
import { defaultSchemas } from '@/lib/data/default-schemas';
export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const { createTable, readonly, createArea } = useChartDB();
const { createTable, readonly, createArea, databaseType } = useChartDB();
const { schemasDisplayed } = useDiagramFilter();
const { openCreateRelationshipDialog, openTableSchemaDialog } = useDialog();
const { openCreateRelationshipDialog } = useDialog();
const { screenToFlowPosition } = useReactFlow();
const { t } = useTranslation();
const { showDBViews } = useLocalConfig();
const { setEditTableModeTable } = useCanvas();
const { isMd: isDesktop } = useBreakpoint('md');
const createTableHandler = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
async (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
if (schemasDisplayed.length > 1) {
openTableSchemaDialog({
onConfirm: ({ schema }) =>
createTable({
x: position.x,
y: position.y,
schema: schema.name,
}),
schemas: schemasDisplayed,
});
} else {
const schema =
schemasDisplayed?.length === 1
? schemasDisplayed[0]?.name
: undefined;
createTable({
x: position.x,
y: position.y,
schema,
});
// Auto-select schema with priority: default schema > first displayed schema > undefined
let schema: string | undefined = undefined;
if (schemasDisplayed.length > 0) {
const defaultSchemaName = defaultSchemas[databaseType];
const defaultSchemaInList = schemasDisplayed.find(
(s) => s.name === defaultSchemaName
);
schema = defaultSchemaInList
? defaultSchemaInList.name
: schemasDisplayed[0]?.name;
}
const newTable = await createTable({
x: position.x,
y: position.y,
schema,
});
if (newTable) {
setEditTableModeTable({ tableId: newTable.id });
}
},
[
createTable,
screenToFlowPosition,
openTableSchemaDialog,
schemasDisplayed,
setEditTableModeTable,
databaseType,
]
);
const createViewHandler = useCallback(
(event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
async (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
if (schemasDisplayed.length > 1) {
openTableSchemaDialog({
onConfirm: ({ schema }) =>
createTable({
x: position.x,
y: position.y,
schema: schema.name,
isView: true,
}),
schemas: schemasDisplayed,
});
} else {
const schema =
schemasDisplayed?.length === 1
? schemasDisplayed[0]?.name
: undefined;
createTable({
x: position.x,
y: position.y,
schema,
isView: true,
});
// Auto-select schema with priority: default schema > first displayed schema > undefined
let schema: string | undefined = undefined;
if (schemasDisplayed.length > 0) {
const defaultSchemaName = defaultSchemas[databaseType];
const defaultSchemaInList = schemasDisplayed.find(
(s) => s.name === defaultSchemaName
);
schema = defaultSchemaInList
? defaultSchemaInList.name
: schemasDisplayed[0]?.name;
}
const newView = await createTable({
x: position.x,
y: position.y,
schema,
isView: true,
});
if (newView) {
setEditTableModeTable({ tableId: newView.id });
}
},
[
createTable,
screenToFlowPosition,
openTableSchemaDialog,
schemasDisplayed,
setEditTableModeTable,
databaseType,
]
);

View File

@@ -101,13 +101,32 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
areas,
]);
// Initialize expanded state with all schemas expanded
useMemo(() => {
const initialExpanded: Record<string, boolean> = {};
treeData.forEach((node) => {
initialExpanded[node.id] = true;
// Sync expanded state with tree data changes - only expand NEW nodes
useEffect(() => {
setExpanded((prev) => {
const currentNodeIds = new Set(treeData.map((n) => n.id));
let hasChanges = false;
const newExpanded: Record<string, boolean> = { ...prev };
// Add any new nodes with expanded=true (preserve existing state)
treeData.forEach((node) => {
if (!(node.id in prev)) {
newExpanded[node.id] = true;
hasChanges = true;
}
});
// Remove nodes that no longer exist (cleanup)
Object.keys(prev).forEach((id) => {
if (!currentNodeIds.has(id)) {
delete newExpanded[id];
hasChanges = true;
}
});
// Only update state if something actually changed (performance)
return hasChanges ? newExpanded : prev;
});
setExpanded(initialExpanded);
}, [treeData]);
// Filter tree data based on search query
@@ -317,6 +336,7 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
expanded={expanded}
setExpanded={setExpanded}
className="py-2"
disableCache={true}
/>
</ScrollArea>
</div>

View File

@@ -30,7 +30,11 @@ import {
import '@xyflow/react/dist/style.css';
import equal from 'fast-deep-equal';
import type { TableNodeType } from './table-node/table-node';
import { TableNode } from './table-node/table-node';
import {
TABLE_RELATIONSHIP_SOURCE_HANDLE_ID_PREFIX,
TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX,
TableNode,
} from './table-node/table-node';
import type { RelationshipEdgeType } from './relationship-edge/relationship-edge';
import { RelationshipEdge } from './relationship-edge/relationship-edge';
import { useChartDB } from '@/hooks/use-chartdb';
@@ -79,6 +83,20 @@ import { useCanvas } from '@/hooks/use-canvas';
import type { AreaNodeType } from './area-node/area-node';
import { AreaNode } from './area-node/area-node';
import type { Area } from '@/lib/domain/area';
import type { TempCursorNodeType } from './temp-cursor-node/temp-cursor-node';
import {
TEMP_CURSOR_HANDLE_ID,
TEMP_CURSOR_NODE_ID,
TempCursorNode,
} from './temp-cursor-node/temp-cursor-node';
import type { TempFloatingEdgeType } from './temp-floating-edge/temp-floating-edge';
import {
TEMP_FLOATING_EDGE_ID,
TempFloatingEdge,
} from './temp-floating-edge/temp-floating-edge';
import type { CreateRelationshipNodeType } from './create-relationship-node/create-relationship-node';
import { CreateRelationshipNode } from './create-relationship-node/create-relationship-node';
import { ConnectionLine } from './connection-line/connection-line';
import {
updateTablesParentAreas,
getTablesInArea,
@@ -97,20 +115,30 @@ import { useClickAway } from 'react-use';
const HIGHLIGHTED_EDGE_Z_INDEX = 1;
const DEFAULT_EDGE_Z_INDEX = 0;
export type EdgeType = RelationshipEdgeType | DependencyEdgeType;
export type EdgeType =
| RelationshipEdgeType
| DependencyEdgeType
| TempFloatingEdgeType;
export type NodeType = TableNodeType | AreaNodeType;
export type NodeType =
| TableNodeType
| AreaNodeType
| TempCursorNodeType
| CreateRelationshipNodeType;
type AddEdgeParams = Parameters<typeof addEdge<EdgeType>>[0];
const edgeTypes: EdgeTypes = {
'relationship-edge': RelationshipEdge,
'dependency-edge': DependencyEdge,
'temp-floating-edge': TempFloatingEdge,
};
const nodeTypes: NodeTypes = {
table: TableNode,
area: AreaNode,
'temp-cursor': TempCursorNode,
'create-relationship': CreateRelationshipNode,
};
const initialEdges: EdgeType[] = [];
@@ -123,12 +151,14 @@ const tableToTableNode = (
filterLoading,
showDBViews,
forceShow,
isRelationshipCreatingTarget = false,
}: {
filter?: DiagramFilter;
databaseType: DatabaseType;
filterLoading: boolean;
showDBViews?: boolean;
forceShow?: boolean;
isRelationshipCreatingTarget?: boolean;
}
): TableNodeType => {
// Always use absolute position for now
@@ -156,6 +186,7 @@ const tableToTableNode = (
data: {
table,
isOverlapping: false,
isRelationshipCreatingTarget,
},
width: table.width ?? MIN_TABLE_SIZE,
hidden,
@@ -200,6 +231,9 @@ const areaToAreaNode = (
width: area.width,
height: area.height,
zIndex: -10,
style: {
zIndex: -10,
},
hidden: !hasVisibleTable || filterLoading,
};
};
@@ -249,6 +283,10 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
showFilter,
setShowFilter,
setEditTableModeTable,
tempFloatingEdge,
endFloatingEdgeCreation,
hoveringTableId,
hideCreateRelationshipNode,
} = useCanvas();
const { filter, loading: filterLoading } = useDiagramFilter();
const { checkIfNewTable } = useDiff();
@@ -270,6 +308,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
forceShow: shouldForceShowTable(table.id),
isRelationshipCreatingTarget: false,
})
)
);
@@ -278,6 +317,11 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
const [snapToGridEnabled, setSnapToGridEnabled] = useState(false);
const [cursorPosition, setCursorPosition] = useState<{
x: number;
y: number;
} | null>(null);
useEffect(() => {
setIsInitialLoadingNodes(true);
}, [initialTables]);
@@ -290,6 +334,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
forceShow: shouldForceShowTable(table.id),
isRelationshipCreatingTarget: false,
})
);
if (equal(initialNodes, nodes)) {
@@ -395,58 +440,62 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
// Check if any edge needs updating
let hasChanges = false;
const newEdges = prevEdges.map((edge): EdgeType => {
const shouldBeHighlighted =
selectedRelationshipIdsSet.has(edge.id) ||
selectedTableIdsSet.has(edge.source) ||
selectedTableIdsSet.has(edge.target);
const newEdges = prevEdges
.filter((e) => e.type !== 'temp-floating-edge')
.map((edge): EdgeType => {
const shouldBeHighlighted =
selectedRelationshipIdsSet.has(edge.id) ||
selectedTableIdsSet.has(edge.source) ||
selectedTableIdsSet.has(edge.target);
const currentHighlighted = edge.data?.highlighted ?? false;
const currentAnimated = edge.animated ?? false;
const currentZIndex = edge.zIndex ?? 0;
const currentHighlighted =
(edge as Exclude<EdgeType, TempFloatingEdgeType>).data
?.highlighted ?? false;
const currentAnimated = edge.animated ?? false;
const currentZIndex = edge.zIndex ?? 0;
// Skip if no changes needed
if (
currentHighlighted === shouldBeHighlighted &&
currentAnimated === shouldBeHighlighted &&
currentZIndex ===
(shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX)
) {
return edge;
}
// Skip if no changes needed
if (
currentHighlighted === shouldBeHighlighted &&
currentAnimated === shouldBeHighlighted &&
currentZIndex ===
(shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX)
) {
return edge;
}
hasChanges = true;
hasChanges = true;
if (edge.type === 'dependency-edge') {
const dependencyEdge = edge as DependencyEdgeType;
return {
...dependencyEdge,
data: {
...dependencyEdge.data!,
highlighted: shouldBeHighlighted,
},
animated: shouldBeHighlighted,
zIndex: shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX,
};
} else {
const relationshipEdge = edge as RelationshipEdgeType;
return {
...relationshipEdge,
data: {
...relationshipEdge.data!,
highlighted: shouldBeHighlighted,
},
animated: shouldBeHighlighted,
zIndex: shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX,
};
}
});
if (edge.type === 'dependency-edge') {
const dependencyEdge = edge as DependencyEdgeType;
return {
...dependencyEdge,
data: {
...dependencyEdge.data!,
highlighted: shouldBeHighlighted,
},
animated: shouldBeHighlighted,
zIndex: shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX,
};
} else {
const relationshipEdge = edge as RelationshipEdgeType;
return {
...relationshipEdge,
data: {
...relationshipEdge.data!,
highlighted: shouldBeHighlighted,
},
animated: shouldBeHighlighted,
zIndex: shouldBeHighlighted
? HIGHLIGHTED_EDGE_Z_INDEX
: DEFAULT_EDGE_Z_INDEX,
};
}
});
return hasChanges ? newEdges : prevEdges;
});
@@ -464,6 +513,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
forceShow: shouldForceShowTable(table.id),
isRelationshipCreatingTarget: false,
});
// Check if table uses the highlighted custom type
@@ -493,6 +543,11 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
})
),
...prevNodes.filter(
(n) =>
n.type === 'temp-cursor' ||
n.type === 'create-relationship'
),
];
// Check if nodes actually changed
@@ -517,6 +572,37 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
shouldForceShowTable,
]);
// Surgical update for relationship creation target highlighting
// This avoids expensive full node recalculation when only the visual state changes
useEffect(() => {
setNodes((nds) => {
let hasChanges = false;
const updatedNodes = nds.map((node) => {
if (node.type !== 'table') return node;
const shouldBeTarget =
!!tempFloatingEdge?.sourceNodeId &&
node.id !== tempFloatingEdge.sourceNodeId;
const isCurrentlyTarget =
node.data.isRelationshipCreatingTarget ?? false;
if (shouldBeTarget !== isCurrentlyTarget) {
hasChanges = true;
return {
...node,
data: {
...node.data,
isRelationshipCreatingTarget: shouldBeTarget,
},
};
}
return node;
});
return hasChanges ? updatedNodes : nds;
});
}, [tempFloatingEdge?.sourceNodeId, setNodes]);
const prevFilter = useRef<DiagramFilter | undefined>(undefined);
useEffect(() => {
if (!equal(filter, prevFilter.current)) {
@@ -1224,6 +1310,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
[setEditTableModeTable]
);
useClickAway(containerRef, exitEditTableMode);
useClickAway(containerRef, hideCreateRelationshipNode);
const shiftPressed = useKeyPress('Shift');
const operatingSystem = getOperatingSystem();
@@ -1240,19 +1327,134 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
[]
);
// Handle mouse move to update cursor position for floating edge
const { screenToFlowPosition } = useReactFlow();
const rafIdRef = useRef<number>();
const handleMouseMove = useCallback(
(event: React.MouseEvent) => {
if (tempFloatingEdge) {
// Throttle using requestAnimationFrame
if (rafIdRef.current) {
return;
}
rafIdRef.current = requestAnimationFrame(() => {
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
setCursorPosition(position);
rafIdRef.current = undefined;
});
}
},
[tempFloatingEdge, screenToFlowPosition]
);
// Cleanup RAF on unmount
useEffect(() => {
return () => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, []);
// Handle escape key to cancel floating edge creation and close relationship node
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (tempFloatingEdge) {
endFloatingEdgeCreation();
setCursorPosition(null);
}
// Also close CreateRelationshipNode if present
hideCreateRelationshipNode();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [tempFloatingEdge, endFloatingEdgeCreation, hideCreateRelationshipNode]);
// Add temporary invisible node at cursor position and edge
const nodesWithCursor = useMemo(() => {
if (!tempFloatingEdge || !cursorPosition) {
return nodes;
}
const tempNode: TempCursorNodeType = {
id: TEMP_CURSOR_NODE_ID,
type: 'temp-cursor',
position: cursorPosition,
data: {},
draggable: false,
selectable: false,
};
return [...nodes, tempNode];
}, [nodes, tempFloatingEdge, cursorPosition]);
const edgesWithFloating = useMemo(() => {
if (!tempFloatingEdge || !cursorPosition) return edges;
let target = TEMP_CURSOR_NODE_ID;
let targetHandle: string | undefined = TEMP_CURSOR_HANDLE_ID;
if (tempFloatingEdge.targetNodeId) {
target = tempFloatingEdge.targetNodeId;
targetHandle = `${TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX}${tempFloatingEdge.targetNodeId}`;
} else if (
hoveringTableId &&
hoveringTableId !== tempFloatingEdge.sourceNodeId
) {
target = hoveringTableId;
targetHandle = `${TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX}${hoveringTableId}`;
}
const tempEdge: TempFloatingEdgeType = {
id: TEMP_FLOATING_EDGE_ID,
source: tempFloatingEdge.sourceNodeId,
sourceHandle: `${TABLE_RELATIONSHIP_SOURCE_HANDLE_ID_PREFIX}${tempFloatingEdge.sourceNodeId}`,
target,
targetHandle,
type: 'temp-floating-edge',
};
return [...edges, tempEdge];
}, [edges, tempFloatingEdge, cursorPosition, hoveringTableId]);
const onPaneClickHandler = useCallback(() => {
if (tempFloatingEdge) {
endFloatingEdgeCreation();
setCursorPosition(null);
}
// Close CreateRelationshipNode if it exists
hideCreateRelationshipNode();
// Exit edit table mode
exitEditTableMode();
}, [
tempFloatingEdge,
exitEditTableMode,
endFloatingEdgeCreation,
hideCreateRelationshipNode,
]);
return (
<CanvasContextMenu>
<div
className="relative flex h-full"
id="canvas"
ref={containerRef}
onMouseMove={handleMouseMove}
>
<ReactFlow
onlyRenderVisibleElements
colorMode={effectiveTheme}
className="canvas-cursor-default nodes-animated"
nodes={nodes}
edges={edges}
nodes={nodesWithCursor}
edges={edgesWithFloating}
onNodesChange={onNodesChangeHandler}
onEdgesChange={onEdgesChangeHandler}
maxZoom={5}
@@ -1271,7 +1473,8 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
panOnScroll={scrollAction === 'pan'}
snapToGrid={shiftPressed || snapToGridEnabled}
snapGrid={[20, 20]}
onPaneClick={exitEditTableMode}
onPaneClick={onPaneClickHandler}
connectionLineComponent={ConnectionLine}
>
<Controls
position="top-left"

View File

@@ -0,0 +1,38 @@
import React from 'react';
import type { ConnectionLineComponentProps } from '@xyflow/react';
import { getSmoothStepPath, Position } from '@xyflow/react';
import type { NodeType } from '../canvas';
export const ConnectionLine: React.FC<
ConnectionLineComponentProps<NodeType>
> = ({ fromX, fromY, toX, toY, fromPosition, toPosition }) => {
const [edgePath] = getSmoothStepPath({
sourceX: fromX,
sourceY: fromY,
sourcePosition: fromPosition ?? Position.Right,
targetX: toX,
targetY: toY,
targetPosition: toPosition ?? Position.Left,
borderRadius: 14,
});
return (
<g>
<path
fill="none"
stroke="#ec4899"
strokeWidth={2}
strokeDasharray="5,5"
d={edgePath}
/>
<circle
cx={toX}
cy={toY}
fill="#fff"
r={3}
stroke="#ec4899"
strokeWidth={1.5}
/>
</g>
);
};

View File

@@ -0,0 +1,321 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { NodeProps, Node } from '@xyflow/react';
import { Button } from '@/components/button/button';
import { useChartDB } from '@/hooks/use-chartdb';
import type { SelectBoxOption } from '@/components/select-box/select-box';
import { SelectBox } from '@/components/select-box/select-box';
import { areFieldTypesCompatible } from '@/lib/data/data-types/data-types';
import { useLayout } from '@/hooks/use-layout';
import { ArrowRight, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { generateId } from '@/lib/utils';
import type { DBField } from '@/lib/domain/db-field';
import { useReactFlow } from '@xyflow/react';
import { useCanvas } from '@/hooks/use-canvas';
export const CREATE_RELATIONSHIP_NODE_ID = '__create-relationship-node__';
const CREATE_NEW_FIELD_VALUE = 'CREATE_NEW';
export type CreateRelationshipNodeType = Node<
{
sourceTableId: string;
targetTableId: string;
},
'create-relationship'
>;
export const CreateRelationshipNode: React.FC<
NodeProps<CreateRelationshipNodeType>
> = React.memo(({ data }) => {
const { sourceTableId, targetTableId } = data;
const { getTable, createRelationship, databaseType, addField } =
useChartDB();
const { hideCreateRelationshipNode } = useCanvas();
const { setEdges } = useReactFlow();
const { openRelationshipFromSidebar } = useLayout();
const [targetFieldId, setTargetFieldId] = useState<string | undefined>();
const [errorMessage, setErrorMessage] = useState('');
const [isVisible, setIsVisible] = useState(false);
const [selectOpen, setSelectOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState<string>('');
const sourceTable = useMemo(
() => getTable(sourceTableId),
[sourceTableId, getTable]
);
const targetTable = useMemo(
() => getTable(targetTableId),
[targetTableId, getTable]
);
// Get the PK field from source table
const sourcePKField = useMemo(() => {
if (!sourceTable) return null;
return (
sourceTable.fields.find((f) => f.primaryKey) ||
sourceTable.fields[0]
);
}, [sourceTable]);
// Get compatible target fields (FK columns)
// Reset state when source or target table changes
useEffect(() => {
setTargetFieldId(undefined);
setSearchTerm('');
setErrorMessage('');
setSelectOpen(true);
}, [sourceTableId, targetTableId]);
const targetFieldOptions = useMemo(() => {
if (!targetTable || !sourcePKField) return [];
const compatibleFields = targetTable.fields
.filter((field) =>
areFieldTypesCompatible(
sourcePKField.type,
field.type,
databaseType
)
)
.map(
(field) =>
({
label: field.name,
value: field.id,
description: `(${field.type.name})`,
}) as SelectBoxOption
);
// Add option to create a new field if user typed a custom name
if (
searchTerm &&
!compatibleFields.find(
(f) => f.label.toLowerCase() === searchTerm.toLowerCase()
)
) {
compatibleFields.push({
label: `Create "${searchTerm}"`,
value: CREATE_NEW_FIELD_VALUE,
description: `(${sourcePKField.type.name})`,
});
}
return compatibleFields;
}, [targetTable, sourcePKField, databaseType, searchTerm]);
// Auto-select first compatible field OR pre-populate suggested name
useEffect(() => {
if (targetFieldOptions.length > 0 && !targetFieldId) {
setTargetFieldId(targetFieldOptions[0].value as string);
} else if (
targetFieldOptions.length === 0 &&
!searchTerm &&
sourceTable &&
sourcePKField
) {
// No compatible fields - suggest a field name based on source table + PK field
const suggestedName =
sourcePKField.name.toLowerCase() === 'id'
? `${sourceTable.name}_${sourcePKField.name}`
: sourcePKField.name;
setSearchTerm(suggestedName);
}
}, [
targetFieldOptions.length,
sourceTable,
sourcePKField,
searchTerm,
targetFieldId,
targetFieldOptions,
]);
// Auto-open the select immediately and trigger animation
useEffect(() => {
setSelectOpen(true);
const rafId = requestAnimationFrame(() => {
setIsVisible(true);
});
return () => cancelAnimationFrame(rafId);
}, []);
const handleCreate = useCallback(async () => {
if (!sourcePKField) return;
try {
let finalTargetFieldId = targetFieldId;
// If user selected "CREATE_NEW", create the field first
if (targetFieldId === CREATE_NEW_FIELD_VALUE && searchTerm) {
const newField: DBField = {
id: generateId(),
name: searchTerm,
type: sourcePKField.type,
unique: false,
nullable: true,
primaryKey: false,
createdAt: Date.now(),
};
try {
await addField(targetTableId, newField);
finalTargetFieldId = newField.id;
} catch (fieldError) {
console.error('Failed to create field:', fieldError);
setErrorMessage('Failed to create new field');
return;
}
}
if (!finalTargetFieldId) {
setErrorMessage('Please select a target field');
return;
}
const relationship = await createRelationship({
sourceTableId,
sourceFieldId: sourcePKField.id,
targetTableId,
targetFieldId: finalTargetFieldId,
});
setEdges((edges) =>
edges.map((edge) =>
edge.id === relationship.id
? { ...edge, selected: true }
: { ...edge, selected: false }
)
);
openRelationshipFromSidebar(relationship.id);
hideCreateRelationshipNode();
} catch (error) {
console.error(error);
setErrorMessage('Failed to create relationship');
}
}, [
sourcePKField,
targetFieldId,
searchTerm,
sourceTableId,
targetTableId,
createRelationship,
addField,
setEdges,
openRelationshipFromSidebar,
hideCreateRelationshipNode,
]);
// Note: Escape key handling is done in canvas.tsx to avoid duplicate listeners
if (!sourceTable || !targetTable || !sourcePKField) {
return null;
}
return (
<div
className={cn(
'pointer-events-auto flex cursor-auto flex-col rounded-lg border border-slate-300 bg-white shadow-xl transition-all duration-150 ease-out dark:border-slate-600 dark:bg-slate-800',
{
'scale-100 opacity-100': isVisible,
'scale-95 opacity-0': !isVisible,
}
)}
style={{
minWidth: '380px',
maxWidth: '420px',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header - draggable */}
<div className="flex cursor-move items-center justify-between gap-2 rounded-t-[7px] border-b bg-sky-600 px-3 py-1 dark:border-slate-600 dark:bg-sky-800">
<div className="text-xs font-semibold text-white">
Create Relationship
</div>
<Button
variant="ghost"
size="sm"
className="size-6 p-0 text-white hover:bg-white/20 hover:text-white dark:hover:bg-white/10"
onClick={hideCreateRelationshipNode}
>
<X className="size-4" />
</Button>
</div>
{/* Content */}
<div className="nodrag flex flex-col gap-3 p-3">
<div className="flex flex-row gap-2">
{/* PK Column (Source) */}
<div className="flex flex-1 flex-col gap-1.5">
<label className="text-xs font-medium text-slate-600 dark:text-slate-300">
From (PK)
</label>
<div className="flex h-7 items-center rounded-md border border-slate-200 bg-slate-50 px-2.5 text-sm font-medium text-slate-700 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-200">
{sourcePKField.name}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{sourceTable.name}
</div>
</div>
{/* Arrow indicator */}
<div className="flex items-center">
<ArrowRight className="size-3.5 text-slate-400 dark:text-slate-500" />
</div>
{/* FK Column (Target) */}
<div className="flex flex-1 flex-col gap-1.5">
<label className="text-xs font-medium text-slate-600 dark:text-slate-300">
To (FK)
</label>
<SelectBox
className="flex h-7 min-h-0 w-full dark:border-slate-200"
popoverClassName="!z-[1001]"
options={targetFieldOptions}
placeholder="Select field..."
inputPlaceholder="Search or Create..."
value={targetFieldId}
onChange={(value) => {
setTargetFieldId(value as string);
}}
emptyPlaceholder="No compatible fields"
onSearchChange={setSearchTerm}
open={selectOpen}
onOpenChange={setSelectOpen}
/>
<div className="text-xs text-slate-500 dark:text-slate-400">
{targetTable.name}
</div>
</div>
</div>
{errorMessage && (
<div className="rounded-md bg-red-50 p-2 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">
{errorMessage}
</div>
)}
{targetFieldOptions.length === 0 && (
<div className="rounded-md bg-yellow-50 p-2 text-xs text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400">
No compatible fields found in target table
</div>
)}
</div>
{/* Footer */}
<div className="flex cursor-move items-center justify-end gap-2 rounded-b-lg border-t border-slate-200 bg-slate-50 px-3 py-2 dark:border-slate-600 dark:bg-slate-900">
<Button
disabled={!targetFieldId || targetFieldOptions.length === 0}
type="button"
onClick={handleCreate}
variant="default"
className="h-7 bg-sky-600 px-3 text-xs text-white hover:bg-sky-700 dark:bg-sky-800 dark:text-white dark:hover:bg-sky-900"
>
Create
</Button>
</div>
</div>
);
});
CreateRelationshipNode.displayName = 'CreateRelationshipNode';

View File

@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import { KeyRound, Trash2 } from 'lucide-react';
import { Input } from '@/components/input/input';
import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field';
import type { DBTable } from '@/lib/domain';
import type { DatabaseType, DBTable } from '@/lib/domain';
import { useUpdateTableField } from '@/hooks/use-update-table-field';
import {
Tooltip,
@@ -13,15 +13,17 @@ import { useTranslation } from 'react-i18next';
import { SelectBox } from '@/components/select-box/select-box';
import { cn } from '@/lib/utils';
import { TableFieldToggle } from './table-field-toggle';
import { requiresNotNull } from '@/lib/data/data-types/data-types';
export interface TableEditModeFieldProps {
table: DBTable;
field: DBField;
focused?: boolean;
databaseType: DatabaseType;
}
export const TableEditModeField: React.FC<TableEditModeFieldProps> = React.memo(
({ table, field, focused = false }) => {
({ table, field, focused = false, databaseType }) => {
const { t } = useTranslation();
const [showHighlight, setShowHighlight] = React.useState(false);
@@ -40,6 +42,8 @@ export const TableEditModeField: React.FC<TableEditModeFieldProps> = React.memo(
const inputRef = React.useRef<HTMLInputElement>(null);
const typeRequiresNotNull = requiresNotNull(field.type.name);
// Animate the highlight after mount if focused
useEffect(() => {
if (focused) {
@@ -102,7 +106,9 @@ export const TableEditModeField: React.FC<TableEditModeFieldProps> = React.memo(
'side_panel.tables_section.table.field_type'
)}
value={field.type.id}
valueSuffix={generateDBFieldSuffix(field)}
valueSuffix={generateDBFieldSuffix(field, {
databaseType,
})}
optionSuffix={(option) =>
generateFieldSuffix(option.value)
}
@@ -119,9 +125,9 @@ export const TableEditModeField: React.FC<TableEditModeFieldProps> = React.memo(
</TooltipTrigger>
<TooltipContent>
{field.type.name}
{field.characterMaximumLength
? `(${field.characterMaximumLength})`
: ''}
{generateDBFieldSuffix(field, {
databaseType,
})}
</TooltipContent>
</Tooltip>
</div>
@@ -132,6 +138,7 @@ export const TableEditModeField: React.FC<TableEditModeFieldProps> = React.memo(
<TableFieldToggle
pressed={nullable}
onPressedChange={handleNullableToggle}
disabled={typeRequiresNotNull}
>
N
</TableFieldToggle>

View File

@@ -1,7 +1,13 @@
import { Input } from '@/components/input/input';
import type { DBTable } from '@/lib/domain';
import { FileType2, X } from 'lucide-react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { FileType2, X, SquarePlus, CircleDotDashed } from 'lucide-react';
import React, {
useEffect,
useState,
useRef,
useCallback,
useMemo,
} from 'react';
import { TableEditModeField } from './table-edit-mode-field';
import { cn } from '@/lib/utils';
import { ScrollArea } from '@/components/scroll-area/scroll-area';
@@ -11,6 +17,15 @@ import { Separator } from '@/components/separator/separator';
import { useChartDB } from '@/hooks/use-chartdb';
import { useUpdateTable } from '@/hooks/use-update-table';
import { useTranslation } from 'react-i18next';
import { useLayout } from '@/hooks/use-layout';
import { SelectBox } from '@/components/select-box/select-box';
import type { SelectBoxOption } from '@/components/select-box/select-box';
import {
databasesWithSchemas,
schemaNameToSchemaId,
} from '@/lib/domain/db-schema';
import type { DBSchema } from '@/lib/domain/db-schema';
import { defaultSchemas } from '@/lib/data/default-schemas';
export interface TableEditModeProps {
table: DBTable;
@@ -25,14 +40,49 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
const scrollAreaRef = useRef<HTMLDivElement>(null);
const fieldRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [isVisible, setIsVisible] = useState(false);
const { createField, updateTable } = useChartDB();
const { createField, updateTable, schemas, databaseType } =
useChartDB();
const { t } = useTranslation();
const { openTableFromSidebar, selectSidebarSection } = useLayout();
const { tableName, handleTableNameChange } = useUpdateTable(table);
const [focusFieldId, setFocusFieldId] = useState<string | undefined>(
focusFieldIdProp
);
const inputRef = useRef<HTMLInputElement>(null);
// Schema-related state
const [isCreatingNewSchema, setIsCreatingNewSchema] = useState(false);
const [newSchemaName, setNewSchemaName] = useState('');
const [selectedSchemaId, setSelectedSchemaId] = useState<string>(() =>
table.schema ? schemaNameToSchemaId(table.schema) : ''
);
// Sync selectedSchemaId when table.schema changes
useEffect(() => {
setSelectedSchemaId(
table.schema ? schemaNameToSchemaId(table.schema) : ''
);
}, [table.schema]);
const supportsSchemas = useMemo(
() => databasesWithSchemas.includes(databaseType),
[databaseType]
);
const defaultSchemaName = useMemo(
() => defaultSchemas?.[databaseType],
[databaseType]
);
const schemaOptions: SelectBoxOption[] = useMemo(
() =>
schemas.map((schema) => ({
value: schema.id,
label: schema.name,
})),
[schemas]
);
useEffect(() => {
setFocusFieldId(focusFieldIdProp);
if (!focusFieldIdProp) {
@@ -115,6 +165,48 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
[updateTable, table.id]
);
const handleSchemaChange = useCallback(
(schemaId: string) => {
const schema = schemas.find((s) => s.id === schemaId);
if (schema) {
updateTable(table.id, { schema: schema.name });
setSelectedSchemaId(schemaId);
}
},
[schemas, updateTable, table.id]
);
const handleCreateNewSchema = useCallback(() => {
if (newSchemaName.trim()) {
const trimmedName = newSchemaName.trim();
const newSchema: DBSchema = {
id: schemaNameToSchemaId(trimmedName),
name: trimmedName,
tableCount: 0,
};
updateTable(table.id, { schema: newSchema.name });
setSelectedSchemaId(newSchema.id);
setIsCreatingNewSchema(false);
setNewSchemaName('');
}
}, [newSchemaName, updateTable, table.id]);
const handleToggleSchemaMode = useCallback(() => {
if (isCreatingNewSchema && newSchemaName.trim()) {
// If we're leaving create mode with a value, create the schema
handleCreateNewSchema();
} else {
// Otherwise just toggle modes
setIsCreatingNewSchema(!isCreatingNewSchema);
setNewSchemaName('');
}
}, [isCreatingNewSchema, newSchemaName, handleCreateNewSchema]);
const openTableInEditor = useCallback(() => {
selectSidebarSection('tables');
openTableFromSidebar(table.id);
}, [selectSidebarSection, openTableFromSidebar, table.id]);
return (
<div
ref={containerRef}
@@ -134,18 +226,60 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
onClick={(e) => e.stopPropagation()}
>
<div
className="h-2 rounded-t-[6px]"
className="h-2 cursor-move rounded-t-[6px]"
style={{ backgroundColor: color }}
></div>
<div className="group flex h-9 items-center justify-between gap-2 bg-slate-200 px-2 dark:bg-slate-900">
<div className="group flex h-9 cursor-move items-center justify-between gap-2 bg-slate-200 px-2 dark:bg-slate-900">
<div className="flex min-w-0 flex-1 items-center gap-2">
<ColorPicker
color={color}
onChange={handleColorChange}
disabled={table.isView}
popoverOnMouseDown={(e) => e.stopPropagation()}
popoverOnClick={(e) => e.stopPropagation()}
/>
{supportsSchemas && !isCreatingNewSchema && (
<SelectBox
options={schemaOptions}
value={selectedSchemaId}
onChange={(value) =>
handleSchemaChange(value as string)
}
placeholder={
defaultSchemaName || 'Select schema'
}
className="h-6 min-h-6 w-20 shrink-0 rounded-sm border-slate-600 bg-background py-0 pl-2 pr-0.5 text-sm"
popoverClassName="w-[200px]"
commandOnMouseDown={(e) => e.stopPropagation()}
commandOnClick={(e) => e.stopPropagation()}
footerButtons={
<Button
variant="ghost"
size="sm"
className="w-full justify-center rounded-none text-xs"
onClick={(e) => {
e.stopPropagation();
handleToggleSchemaMode();
}}
>
<SquarePlus className="!size-3.5" />
Create new schema
</Button>
}
/>
)}
{supportsSchemas && isCreatingNewSchema && (
<Input
value={newSchemaName}
onChange={(e) =>
setNewSchemaName(e.target.value)
}
placeholder={`Enter schema name${defaultSchemaName ? ` (e.g. ${defaultSchemaName})` : ''}`}
className="h-6 w-28 shrink-0 rounded-sm border-slate-600 bg-background text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateNewSchema();
} else if (e.key === 'Escape') {
handleToggleSchemaMode();
}
}}
onBlur={handleToggleSchemaMode}
autoFocus
/>
)}
<Input
ref={inputRef}
className="h-6 flex-1 rounded-sm border-slate-600 bg-background text-sm"
@@ -156,14 +290,24 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
}
/>
</div>
<Button
variant="ghost"
size="sm"
className="size-6 p-0 hover:bg-slate-300 dark:hover:bg-slate-700"
onClick={onClose}
>
<X className="size-4" />
</Button>
<div className="flex shrink-0 flex-row gap-1">
<Button
variant="ghost"
size="sm"
className="size-6 p-0 text-slate-500 hover:bg-slate-300 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200"
onClick={openTableInEditor}
>
<CircleDotDashed className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="size-6 p-0 hover:bg-slate-300 dark:hover:bg-slate-700"
onClick={onClose}
>
<X className="size-4" />
</Button>
</div>
</div>
<ScrollArea ref={scrollAreaRef} className="nodrag flex-1 p-2">
@@ -173,21 +317,38 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
table={table}
field={field}
focused={focusFieldId === field.id}
databaseType={databaseType}
/>
</div>
))}
</ScrollArea>
<Separator />
<div className="flex items-center justify-between p-2">
<Button
variant="outline"
className="h-8 p-2 text-xs"
onClick={handleAddField}
>
<FileType2 className="mr-1 h-4" />
{t('side_panel.tables_section.table.add_field')}
</Button>
<div className="flex cursor-move items-center justify-between p-2">
<div className="flex items-center gap-2">
{!table.isView ? (
<>
<ColorPicker
color={color}
onChange={handleColorChange}
popoverOnMouseDown={(e) =>
e.stopPropagation()
}
popoverOnClick={(e) => e.stopPropagation()}
/>
</>
) : (
<div />
)}
<Button
variant="outline"
className="h-8 p-2 text-xs"
onClick={handleAddField}
>
<FileType2 className="mr-1 h-4" />
{t('side_panel.tables_section.table.add_field')}
</Button>
</div>
<span className="text-xs font-medium text-muted-foreground">
{table.fields.length}{' '}
{t('side_panel.tables_section.table.fields')}

View File

@@ -12,7 +12,6 @@ import type { DBTable } from '@/lib/domain/db-table';
import { Copy, Pencil, Trash2, Workflow } from 'lucide-react';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDialog } from '@/hooks/use-dialog';
import { useCanvas } from '@/hooks/use-canvas';
export interface TableNodeContextMenuProps {
@@ -26,37 +25,56 @@ export const TableNodeContextMenu: React.FC<
const { closeAllTablesInSidebar } = useLayout();
const { t } = useTranslation();
const { isMd: isDesktop } = useBreakpoint('md');
const { openCreateRelationshipDialog } = useDialog();
const { setEditTableModeTable } = useCanvas();
const { setEditTableModeTable, startFloatingEdgeCreation } = useCanvas();
const duplicateTableHandler = useCallback(() => {
const clonedTable = cloneTable(table);
const duplicateTableHandler: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.stopPropagation();
const clonedTable = cloneTable(table);
clonedTable.name = `${clonedTable.name}_copy`;
clonedTable.x += 30;
clonedTable.y += 50;
clonedTable.name = `${clonedTable.name}_copy`;
clonedTable.x += 30;
clonedTable.y += 50;
createTable(clonedTable);
}, [createTable, table]);
createTable(clonedTable);
},
[createTable, table]
);
const editTableHandler = useCallback(() => {
if (readonly) {
return;
}
const editTableHandler: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.stopPropagation();
if (readonly) {
return;
}
closeAllTablesInSidebar();
setEditTableModeTable({ tableId: table.id });
}, [table.id, setEditTableModeTable, closeAllTablesInSidebar, readonly]);
closeAllTablesInSidebar();
setEditTableModeTable({ tableId: table.id });
},
[table.id, setEditTableModeTable, closeAllTablesInSidebar, readonly]
);
const removeTableHandler = useCallback(() => {
removeTable(table.id);
}, [removeTable, table.id]);
const removeTableHandler: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.stopPropagation();
removeTable(table.id);
},
[removeTable, table.id]
);
const addRelationshipHandler = useCallback(() => {
openCreateRelationshipDialog({
sourceTableId: table.id,
});
}, [openCreateRelationshipDialog, table.id]);
const addRelationshipHandler: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.stopPropagation();
startFloatingEdgeCreation({
sourceNodeId: table.id,
});
},
[startFloatingEdgeCreation, table.id]
);
if (!isDesktop || readonly) {
return <>{children}</>;

View File

@@ -67,6 +67,7 @@ const arePropsEqual = (
nextProps.field.characterMaximumLength &&
prevProps.field.precision === nextProps.field.precision &&
prevProps.field.scale === nextProps.field.scale &&
prevProps.field.isArray === nextProps.field.isArray &&
prevProps.focused === nextProps.focused &&
prevProps.highlighted === nextProps.highlighted &&
prevProps.visible === nextProps.visible &&
@@ -77,7 +78,8 @@ const arePropsEqual = (
export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
({ field, focused, tableNodeId, highlighted, visible, isConnectable }) => {
const { relationships, readonly, highlightedCustomType } = useChartDB();
const { relationships, readonly, highlightedCustomType, databaseType } =
useChartDB();
const updateNodeInternals = useUpdateNodeInternals();
const connection = useConnection();
@@ -152,6 +154,7 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
getFieldNewCharacterMaximumLength,
getFieldNewPrecision,
getFieldNewScale,
getFieldNewIsArray,
checkIfFieldHasChange,
isSummaryOnly,
} = useDiff();
@@ -159,13 +162,18 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
const [diffState, setDiffState] = useState<{
isDiffFieldRemoved: boolean;
isDiffNewField: boolean;
fieldDiffChangedName: string | null;
fieldDiffChangedType: DBField['type'] | null;
fieldDiffChangedNullable: boolean | null;
fieldDiffChangedCharacterMaximumLength: string | null;
fieldDiffChangedScale: number | null;
fieldDiffChangedPrecision: number | null;
fieldDiffChangedPrimaryKey: boolean | null;
fieldDiffChangedName: ReturnType<typeof getFieldNewName>;
fieldDiffChangedType: ReturnType<typeof getFieldNewType>;
fieldDiffChangedNullable: ReturnType<typeof getFieldNewNullable>;
fieldDiffChangedCharacterMaximumLength: ReturnType<
typeof getFieldNewCharacterMaximumLength
>;
fieldDiffChangedScale: ReturnType<typeof getFieldNewScale>;
fieldDiffChangedPrecision: ReturnType<typeof getFieldNewPrecision>;
fieldDiffChangedPrimaryKey: ReturnType<
typeof getFieldNewPrimaryKey
>;
fieldDiffChangedIsArray: ReturnType<typeof getFieldNewIsArray>;
isDiffFieldChanged: boolean;
}>({
isDiffFieldRemoved: false,
@@ -177,6 +185,7 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
fieldDiffChangedScale: null,
fieldDiffChangedPrecision: null,
fieldDiffChangedPrimaryKey: null,
fieldDiffChangedIsArray: null,
isDiffFieldChanged: false,
});
@@ -210,6 +219,9 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
fieldDiffChangedPrecision: getFieldNewPrecision({
fieldId: field.id,
}),
fieldDiffChangedIsArray: getFieldNewIsArray({
fieldId: field.id,
}),
isDiffFieldChanged: checkIfFieldHasChange({
fieldId: field.id,
tableId: tableNodeId,
@@ -228,6 +240,7 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
getFieldNewCharacterMaximumLength,
getFieldNewPrecision,
getFieldNewScale,
getFieldNewIsArray,
field.id,
tableNodeId,
]);
@@ -243,8 +256,23 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
fieldDiffChangedCharacterMaximumLength,
fieldDiffChangedScale,
fieldDiffChangedPrecision,
fieldDiffChangedIsArray,
} = diffState;
const isFieldAttributeChanged = useMemo(() => {
return (
fieldDiffChangedCharacterMaximumLength ||
fieldDiffChangedScale ||
fieldDiffChangedPrecision ||
fieldDiffChangedIsArray
);
}, [
fieldDiffChangedCharacterMaximumLength,
fieldDiffChangedScale,
fieldDiffChangedPrecision,
fieldDiffChangedIsArray,
]);
const isCustomTypeHighlighted = useMemo(() => {
if (!highlightedCustomType) return false;
return field.type.name === highlightedCustomType.name;
@@ -338,17 +366,14 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
</>
)}
<div
className={cn(
'flex items-center gap-1 min-w-0 flex-1 text-left',
{
'font-semibold': field.primaryKey || field.unique,
}
)}
className={cn('flex items-center gap-1 min-w-0 text-left', {
'font-semibold': field.primaryKey || field.unique,
})}
>
{isDiffFieldRemoved ? (
<SquareMinus className="size-3.5 text-red-800 dark:text-red-200" />
<SquareMinus className="size-3.5 shrink-0 text-red-800 dark:text-red-200" />
) : isDiffNewField ? (
<SquarePlus className="size-3.5 text-green-800 dark:text-green-200" />
<SquarePlus className="size-3.5 shrink-0 text-green-800 dark:text-green-200" />
) : isDiffFieldChanged && !isSummaryOnly ? (
<SquareDot className="size-3.5 shrink-0 text-sky-800 dark:text-sky-200" />
) : null}
@@ -368,9 +393,9 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
>
{fieldDiffChangedName ? (
<>
{field.name}{' '}
{fieldDiffChangedName.old}{' '}
<span className="font-medium"></span>{' '}
{fieldDiffChangedName}
{fieldDiffChangedName.new}
</>
) : (
field.name
@@ -388,14 +413,17 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
) : null}
</div>
<div className="ml-2 flex shrink-0 items-center justify-end gap-1.5">
{(field.primaryKey &&
fieldDiffChangedPrimaryKey === null) ||
fieldDiffChangedPrimaryKey ? (
<div
className={cn(
'ml-auto flex shrink-0 items-center gap-1 min-w-0',
!readonly ? 'group-hover:hidden' : ''
)}
>
{(field.primaryKey && !fieldDiffChangedPrimaryKey?.old) ||
fieldDiffChangedPrimaryKey?.new ? (
<div
className={cn(
'text-muted-foreground',
!readonly ? 'group-hover:hidden' : '',
'text-muted-foreground shrink-0',
isDiffFieldRemoved
? 'text-red-800 dark:text-red-200'
: '',
@@ -413,12 +441,9 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
<KeyRound size={14} />
</div>
) : null}
<div
className={cn(
'content-center text-right text-xs text-muted-foreground overflow-hidden max-w-[8rem]',
field.primaryKey ? 'min-w-0' : 'min-w-[3rem]',
!readonly ? 'group-hover:hidden' : '',
'text-right text-xs text-muted-foreground overflow-hidden min-w-0',
isDiffFieldRemoved
? 'text-red-800 dark:text-red-200'
: '',
@@ -434,35 +459,89 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
)}
>
<span className="block truncate">
{fieldDiffChangedType ? (
{isFieldAttributeChanged || fieldDiffChangedType ? (
<>
<span className="line-through">
{field.type.name.split(' ')[0]}
{
(
fieldDiffChangedType?.old
?.name ?? field.type.name
).split(' ')[0]
}
{showFieldAttributes
? generateDBFieldSuffix(
{
...field,
...{
precision:
fieldDiffChangedPrecision?.old ??
field.precision,
scale:
fieldDiffChangedScale?.old ??
field.scale,
characterMaximumLength:
fieldDiffChangedCharacterMaximumLength?.old ??
field.characterMaximumLength,
isArray:
fieldDiffChangedIsArray?.old ??
field.isArray,
},
},
{
databaseType,
}
)
: field.isArray
? '[]'
: ''}
</span>{' '}
{fieldDiffChangedType.name.split(' ')[0]}
{
(
fieldDiffChangedType?.new?.name ??
field.type.name
).split(' ')[0]
}
{showFieldAttributes
? generateDBFieldSuffix(
{
...field,
...{
precision:
fieldDiffChangedPrecision?.new ??
field.precision,
scale:
fieldDiffChangedScale?.new ??
field.scale,
characterMaximumLength:
fieldDiffChangedCharacterMaximumLength?.new ??
field.characterMaximumLength,
isArray:
fieldDiffChangedIsArray?.new ??
field.isArray,
},
},
{
databaseType,
}
)
: (fieldDiffChangedIsArray?.new ??
field.isArray)
? '[]'
: ''}
</>
) : (
`${field.type.name.split(' ')[0]}${
showFieldAttributes
? generateDBFieldSuffix({
...field,
...{
precision:
fieldDiffChangedPrecision ??
field.precision,
scale:
fieldDiffChangedScale ??
field.scale,
characterMaximumLength:
fieldDiffChangedCharacterMaximumLength ??
field.characterMaximumLength,
},
? generateDBFieldSuffix(field, {
databaseType,
})
: ''
: field.isArray
? '[]'
: ''
}`
)}
{fieldDiffChangedNullable !== null ? (
fieldDiffChangedNullable ? (
{fieldDiffChangedNullable ? (
fieldDiffChangedNullable.new ? (
<span className="font-semibold">?</span>
) : (
<span className="line-through">?</span>
@@ -474,21 +553,21 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
)}
</span>
</div>
{readonly ? null : (
<div className="hidden flex-row group-hover:flex">
<Button
variant="ghost"
className="size-6 p-0 hover:bg-primary-foreground"
onClick={(e) => {
e.stopPropagation();
openEditTableOnField();
}}
>
<Pencil className="!size-3.5 text-pink-600" />
</Button>
</div>
)}
</div>
{readonly ? null : (
<div className="ml-2 hidden shrink-0 flex-row group-hover:flex">
<Button
variant="ghost"
className="size-6 p-0 hover:bg-primary-foreground"
onClick={(e) => {
e.stopPropagation();
openEditTableOnField();
}}
>
<Pencil className="!size-3.5 text-pink-600" />
</Button>
</div>
)}
</div>
);
},

View File

@@ -6,7 +6,13 @@ import React, {
useEffect,
} from 'react';
import type { NodeProps, Node } from '@xyflow/react';
import { NodeResizer, useConnection, useStore } from '@xyflow/react';
import {
NodeResizer,
useConnection,
useStore,
Handle,
Position,
} from '@xyflow/react';
import { Button } from '@/components/button/button';
import {
ChevronsLeftRight,
@@ -47,6 +53,9 @@ import { TableNodeStatus } from './table-node-status/table-node-status';
import { TableEditMode } from './table-edit-mode/table-edit-mode';
import { useCanvas } from '@/hooks/use-canvas';
export const TABLE_RELATIONSHIP_SOURCE_HANDLE_ID_PREFIX = 'table_rel_source_';
export const TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX = 'table_rel_target_';
export type TableNodeType = Node<
{
table: DBTable;
@@ -54,6 +63,7 @@ export type TableNodeType = Node<
highlightOverlappingTables?: boolean;
hasHighlightedCustomType?: boolean;
highlightTable?: boolean;
isRelationshipCreatingTarget?: boolean;
},
'table'
>;
@@ -69,6 +79,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
highlightOverlappingTables,
hasHighlightedCustomType,
highlightTable,
isRelationshipCreatingTarget,
},
}) => {
const { updateTable, relationships, readonly } = useChartDB();
@@ -81,7 +92,13 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
const [expanded, setExpanded] = useState(table.expanded ?? false);
const { t } = useTranslation();
const [isHovering, setIsHovering] = useState(false);
const { setEditTableModeTable, editTableModeTable } = useCanvas();
const {
setEditTableModeTable,
editTableModeTable,
setHoveringTableId,
showCreateRelationshipNode,
tempFloatingEdge,
} = useCanvas();
// Get edit mode state directly from context
const editTableMode = useMemo(
@@ -138,7 +155,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
);
const tableColor = useMemo(() => {
if (tableChangedColor) {
return tableChangedColor;
return tableChangedColor.new;
}
return table.color;
@@ -314,11 +331,20 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
editModeInitialFieldCount,
]);
const isPartOfCreatingRelationship = useMemo(
() =>
tempFloatingEdge?.sourceNodeId === id ||
(isRelationshipCreatingTarget &&
tempFloatingEdge?.targetNodeId === id) ||
isHovering,
[tempFloatingEdge, id, isRelationshipCreatingTarget, isHovering]
);
const tableClassName = useMemo(
() =>
cn(
'flex w-full flex-col border-2 bg-slate-50 dark:bg-slate-950 rounded-lg shadow-sm transition-transform duration-300',
selected || isTarget
selected || isTarget || isPartOfCreatingRelationship
? 'border-pink-600'
: 'border-slate-500 dark:border-slate-700',
isOverlapping
@@ -363,6 +389,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
isDiffTableRemoved,
isTarget,
editTableMode,
isPartOfCreatingRelationship,
]
);
@@ -400,11 +427,33 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
className={tableClassName}
onClick={(e) => {
if (e.detail === 2 && !readonly) {
e.stopPropagation();
enterEditTableMode();
} else if (e.detail === 1 && !readonly) {
// Handle single click
if (
isRelationshipCreatingTarget &&
tempFloatingEdge
) {
e.stopPropagation();
showCreateRelationshipNode({
sourceTableId:
tempFloatingEdge.sourceNodeId,
targetTableId: table.id,
x: e.clientX,
y: e.clientY,
});
}
}
}}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onMouseEnter={() => {
setIsHovering(true);
setHoveringTableId(table.id);
}}
onMouseLeave={() => {
setIsHovering(false);
setHoveringTableId(null);
}}
>
<NodeResizer
isVisible={focused}
@@ -414,6 +463,25 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
shouldResize={(event) => event.dy === 0}
handleClassName="!hidden"
/>
{/* Center handle for floating edge creation */}
{!readonly ? (
<Handle
id={`${TABLE_RELATIONSHIP_SOURCE_HANDLE_ID_PREFIX}${table.id}`}
type="source"
position={Position.Top}
className="!invisible !left-1/2 !top-1/2 !h-1 !w-1 !-translate-x-1/2 !-translate-y-1/2 !transform"
/>
) : null}
{/* Target handle covering entire table for floating edge creation */}
{!readonly ? (
<Handle
id={`${TABLE_RELATIONSHIP_TARGET_HANDLE_ID_PREFIX}${table.id}`}
type="target"
position={Position.Top}
className="!absolute !left-0 !top-0 !h-full !w-full !transform-none !rounded-none !border-none !opacity-0"
isConnectable={isRelationshipCreatingTarget}
/>
) : null}
<TableNodeDependencyIndicator
table={table}
focused={focused}
@@ -476,13 +544,13 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
{tableChangedName ? (
<Label className="flex h-5 items-center 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">
<span className="truncate">
{table.name}
{tableChangedName.old}
</span>
<span className="mx-1 font-semibold">
</span>
<span className="truncate">
{tableChangedName}
{tableChangedName.new}
</span>
</Label>
) : isDiffNewTable ? (

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { type NodeProps, type Node, Handle, Position } from '@xyflow/react';
export const TEMP_CURSOR_NODE_ID = '__temp_cursor_node__';
export const TEMP_CURSOR_HANDLE_ID = '__temp-cursor-target__';
export type TempCursorNodeType = Node<
{
// Empty data object - this is just a cursor position marker
},
'temp-cursor'
>;
export const TempCursorNode: React.FC<NodeProps<TempCursorNodeType>> =
React.memo(() => {
// Invisible node that just serves as a connection point
return (
<div
style={{
width: 1,
height: 1,
opacity: 0,
pointerEvents: 'none',
}}
>
<Handle
id={TEMP_CURSOR_HANDLE_ID}
className="!invisible"
position={Position.Right}
type="target"
/>
</div>
);
});
TempCursorNode.displayName = 'TempCursorNode';

View File

@@ -0,0 +1,64 @@
import React from 'react';
import type { Edge, EdgeProps } from '@xyflow/react';
import { getSmoothStepPath, Position } from '@xyflow/react';
export const TEMP_FLOATING_EDGE_ID = '__temp_floating_edge__';
export type TempFloatingEdgeType = Edge<
{
// No relationship data - this is a temporary visual edge
},
'temp-floating-edge'
>;
export const TempFloatingEdge: React.FC<EdgeProps<TempFloatingEdgeType>> =
React.memo(
({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition = Position.Right,
targetPosition = Position.Left,
}) => {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
borderRadius: 14,
});
return (
<g>
<path
id={id}
fill="none"
stroke="#ec4899"
strokeWidth={2}
strokeDasharray="5,5"
d={edgePath}
style={{
pointerEvents: 'none',
}}
/>
<circle
cx={targetX}
cy={targetY}
fill="#fff"
r={3}
stroke="#ec4899"
strokeWidth={1.5}
style={{
pointerEvents: 'none',
}}
/>
</g>
);
}
);
TempFloatingEdge.displayName = 'TempFloatingEdge';

View File

@@ -27,7 +27,7 @@ import ChartDBLogo from '@/assets/logo-light.png';
import ChartDBDarkLogo from '@/assets/logo-dark.png';
import { useTheme } from '@/hooks/use-theme';
import { useChartDB } from '@/hooks/use-chartdb';
import { DatabaseType } from '@/lib/domain/database-type';
import { supportsCustomTypes } from '@/lib/domain/database-capabilities';
import { useDialog } from '@/hooks/use-dialog';
import { Separator } from '@/components/separator/separator';
@@ -110,7 +110,7 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = () => {
},
active: selectedSidebarSection === 'areas',
},
...(databaseType === DatabaseType.POSTGRESQL
...(supportsCustomTypes(databaseType)
? [
{
title: t('editor_sidebar.custom_types'),

View File

@@ -15,7 +15,7 @@ import { useChartDB } from '@/hooks/use-chartdb';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { AreasSection } from './areas-section/areas-section';
import { CustomTypesSection } from './custom-types-section/custom-types-section';
import { DatabaseType } from '@/lib/domain/database-type';
import { supportsCustomTypes } from '@/lib/domain/database-capabilities';
import { DBMLSection } from './dbml-section/dbml-section';
import { RefsSection } from './refs-section/refs-section';
@@ -54,7 +54,7 @@ export const SidePanel: React.FC<SidePanelProps> = () => {
<SelectItem value="areas">
{t('side_panel.areas_section.areas')}
</SelectItem>
{databaseType === DatabaseType.POSTGRESQL ? (
{supportsCustomTypes(databaseType) ? (
<SelectItem value="customTypes">
{t(
'side_panel.custom_types_section.custom_types'

View File

@@ -8,6 +8,8 @@ import type { FieldAttributeRange } from '@/lib/data/data-types/data-types';
import {
findDataTypeDataById,
supportsAutoIncrementDataType,
supportsArrayDataType,
autoIncrementAlwaysOn,
} from '@/lib/data/data-types/data-types';
import {
Popover,
@@ -89,6 +91,7 @@ export const TableFieldPopover: React.FC<TableFieldPopoverProps> = ({
unique: localField.unique,
default: localField.default,
increment: localField.increment,
isArray: localField.isArray,
});
}
prevFieldRef.current = localField;
@@ -104,6 +107,23 @@ export const TableFieldPopover: React.FC<TableFieldPopoverProps> = ({
[field.type.name]
);
const supportsArray = useMemo(
() => supportsArrayDataType(field.type.name, databaseType),
[field.type.name, databaseType]
);
// Check if this is a SERIAL-type that is inherently auto-incrementing
const forceAutoIncrement = useMemo(
() => autoIncrementAlwaysOn(field.type.name) && !localField.nullable,
[field.type.name, localField.nullable]
);
// Auto-increment is disabled if the field is nullable (auto-increment requires NOT NULL)
const isIncrementDisabled = useMemo(
() => localField.nullable || readonly || forceAutoIncrement,
[localField.nullable, readonly, forceAutoIncrement]
);
return (
<Popover
open={isOpen}
@@ -159,10 +179,12 @@ export const TableFieldPopover: React.FC<TableFieldPopoverProps> = ({
)}
</Label>
<Checkbox
checked={localField.increment ?? false}
disabled={
!localField.primaryKey || readonly
checked={
forceAutoIncrement
? true
: (localField.increment ?? false)
}
disabled={isIncrementDisabled}
onCheckedChange={(value) =>
setLocalField((current) => ({
...current,
@@ -172,6 +194,26 @@ export const TableFieldPopover: React.FC<TableFieldPopoverProps> = ({
/>
</div>
) : null}
{supportsArray ? (
<div className="flex items-center justify-between">
<Label
htmlFor="isArray"
className="text-subtitle"
>
Array
</Label>
<Checkbox
checked={localField.isArray ?? false}
disabled={readonly}
onCheckedChange={(value) =>
setLocalField((current) => ({
...current,
isArray: !!value,
}))
}
/>
</div>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="default" className="text-subtitle">
{t(

View File

@@ -2,7 +2,6 @@ import React from 'react';
import { GripVertical, KeyRound } from 'lucide-react';
import { Input } from '@/components/input/input';
import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field';
import { useChartDB } from '@/hooks/use-chartdb';
import { useUpdateTableField } from '@/hooks/use-update-table-field';
import {
Tooltip,
@@ -15,13 +14,16 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { SelectBox } from '@/components/select-box/select-box';
import { TableFieldPopover } from './table-field-modal/table-field-modal';
import type { DBTable } from '@/lib/domain';
import type { DatabaseType, DBTable } from '@/lib/domain';
import { requiresNotNull } from '@/lib/data/data-types/data-types';
export interface TableFieldProps {
table: DBTable;
field: DBField;
updateField: (attrs: Partial<DBField>) => void;
removeField: () => void;
databaseType: DatabaseType;
readonly?: boolean;
}
export const TableField: React.FC<TableFieldProps> = ({
@@ -29,8 +31,9 @@ export const TableField: React.FC<TableFieldProps> = ({
field,
updateField,
removeField,
databaseType,
readonly = false,
}) => {
const { databaseType, readonly } = useChartDB();
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition } =
@@ -53,6 +56,8 @@ export const TableField: React.FC<TableFieldProps> = ({
transition,
};
const typeRequiresNotNull = requiresNotNull(field.type.name);
return (
<div
className="flex flex-1 touch-none flex-row justify-between gap-2 p-1"
@@ -99,7 +104,9 @@ export const TableField: React.FC<TableFieldProps> = ({
'side_panel.tables_section.table.field_type'
)}
value={field.type.id}
valueSuffix={generateDBFieldSuffix(field)}
valueSuffix={generateDBFieldSuffix(field, {
databaseType,
})}
optionSuffix={(option) =>
generateFieldSuffix(option.value)
}
@@ -126,7 +133,7 @@ export const TableField: React.FC<TableFieldProps> = ({
<TableFieldToggle
pressed={nullable}
onPressedChange={handleNullableToggle}
disabled={readonly}
disabled={readonly || typeRequiresNotNull}
>
N
</TableFieldToggle>

View File

@@ -49,6 +49,7 @@ export const TableListItemContent: React.FC<TableListItemContentProps> = ({
updateIndex,
updateTable,
readonly,
databaseType,
} = useChartDB();
const { t } = useTranslation();
const { color } = table;
@@ -183,6 +184,8 @@ export const TableListItemContent: React.FC<TableListItemContentProps> = ({
removeField={() =>
removeField(table.id, field.id)
}
databaseType={databaseType}
readonly={readonly}
/>
))}
</SortableContext>

View File

@@ -2418,9 +2418,10 @@ export const examples: Example[] = [
id: 'yqrnjmizqeu2w7mpfhze3clbj',
name: 'special_features',
type: {
id: 'array',
name: 'array',
id: 'text',
name: 'text',
},
isArray: true,
primaryKey: false,
unique: false,
nullable: true,