mirror of
https://github.com/chartdb/chartdb.git
synced 2025-10-27 10:03:59 +00:00
Compare commits
18 Commits
v1.16.0
...
jf/add_arr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a99c0b96f | ||
|
|
9e8979d062 | ||
|
|
9ed27cf30c | ||
|
|
2c4b344efb | ||
|
|
ccb29e0a57 | ||
|
|
7d811de097 | ||
|
|
62dec48572 | ||
|
|
49328d8fbd | ||
|
|
459698b5d0 | ||
|
|
7ad0e7712d | ||
|
|
34475add32 | ||
|
|
38fedcec0c | ||
|
|
498655e7b7 | ||
|
|
bcd8aa9378 | ||
|
|
b15bc945ac | ||
|
|
c3c646bf7c | ||
|
|
57b3b8777f | ||
|
|
bb033091b1 |
@@ -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"
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -308,7 +308,7 @@ export const ar: LanguageTranslation = {
|
||||
cancel: 'إلغاء',
|
||||
import_from_file: 'استيراد من ملف',
|
||||
back: 'رجوع',
|
||||
empty_diagram: 'مخطط فارغ',
|
||||
empty_diagram: 'قاعدة بيانات فارغة',
|
||||
continue: 'متابعة',
|
||||
import: 'استيراد',
|
||||
},
|
||||
|
||||
@@ -310,7 +310,7 @@ export const bn: LanguageTranslation = {
|
||||
cancel: 'বাতিল করুন',
|
||||
back: 'ফিরে যান',
|
||||
import_from_file: 'ফাইল থেকে আমদানি করুন',
|
||||
empty_diagram: 'ফাঁকা চিত্র',
|
||||
empty_diagram: 'খালি ডাটাবেস',
|
||||
continue: 'চালিয়ে যান',
|
||||
import: 'আমদানি করুন',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -310,7 +310,7 @@ export const gu: LanguageTranslation = {
|
||||
cancel: 'રદ કરો',
|
||||
back: 'પાછા',
|
||||
import_from_file: 'ફાઇલમાંથી આયાત કરો',
|
||||
empty_diagram: 'ખાલી ડાયાગ્રામ',
|
||||
empty_diagram: 'ખાલી ડેટાબેસ',
|
||||
continue: 'ચાલુ રાખો',
|
||||
import: 'આયાત કરો',
|
||||
},
|
||||
|
||||
@@ -312,7 +312,7 @@ export const hi: LanguageTranslation = {
|
||||
back: 'वापस',
|
||||
// TODO: Translate
|
||||
import_from_file: 'Import from File',
|
||||
empty_diagram: 'खाली आरेख',
|
||||
empty_diagram: 'खाली डेटाबेस',
|
||||
continue: 'जारी रखें',
|
||||
import: 'आयात करें',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -314,7 +314,7 @@ export const ja: LanguageTranslation = {
|
||||
back: '戻る',
|
||||
// TODO: Translate
|
||||
import_from_file: 'Import from File',
|
||||
empty_diagram: '空のダイアグラム',
|
||||
empty_diagram: '空のデータベース',
|
||||
continue: '続行',
|
||||
import: 'インポート',
|
||||
},
|
||||
|
||||
@@ -309,7 +309,7 @@ export const ko_KR: LanguageTranslation = {
|
||||
cancel: '취소',
|
||||
back: '뒤로가기',
|
||||
import_from_file: '파일에서 가져오기',
|
||||
empty_diagram: '빈 다이어그램으로 시작',
|
||||
empty_diagram: '빈 데이터베이스',
|
||||
continue: '계속',
|
||||
import: '가져오기',
|
||||
},
|
||||
|
||||
@@ -315,7 +315,7 @@ export const mr: LanguageTranslation = {
|
||||
// TODO: Add translations
|
||||
import_from_file: 'Import from File',
|
||||
back: 'मागे',
|
||||
empty_diagram: 'रिक्त आरेख',
|
||||
empty_diagram: 'रिक्त डेटाबेस',
|
||||
continue: 'सुरू ठेवा',
|
||||
import: 'आयात करा',
|
||||
},
|
||||
|
||||
@@ -311,7 +311,7 @@ export const ne: LanguageTranslation = {
|
||||
cancel: 'रद्द गर्नुहोस्',
|
||||
import_from_file: 'फाइलबाट आयात गर्नुहोस्',
|
||||
back: 'फर्क',
|
||||
empty_diagram: 'रिक्त डायाग्राम',
|
||||
empty_diagram: 'खाली डाटाबेस',
|
||||
continue: 'जारी राख्नुहोस्',
|
||||
import: 'आयात गर्नुहोस्',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -307,7 +307,7 @@ export const ru: LanguageTranslation = {
|
||||
cancel: 'Отменить',
|
||||
back: 'Назад',
|
||||
import_from_file: 'Импортировать из файла',
|
||||
empty_diagram: 'Пустая диаграмма',
|
||||
empty_diagram: 'Пустая база данных',
|
||||
continue: 'Продолжить',
|
||||
import: 'Импорт',
|
||||
},
|
||||
|
||||
@@ -312,7 +312,7 @@ export const te: LanguageTranslation = {
|
||||
// TODO: Translate
|
||||
import_from_file: 'Import from File',
|
||||
back: 'తిరుగు',
|
||||
empty_diagram: 'ఖాళీ చిత్రము',
|
||||
empty_diagram: 'ఖాళీ డేటాబేస్',
|
||||
continue: 'కొనసాగించు',
|
||||
import: 'డిగుమతి',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -308,7 +308,7 @@ export const uk: LanguageTranslation = {
|
||||
cancel: 'Скасувати',
|
||||
back: 'Назад',
|
||||
import_from_file: 'Імпортувати з файлу',
|
||||
empty_diagram: 'Порожня діаграма',
|
||||
empty_diagram: 'Порожня база даних',
|
||||
continue: 'Продовжити',
|
||||
import: 'Імпорт',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -306,7 +306,7 @@ export const zh_CN: LanguageTranslation = {
|
||||
cancel: '取消',
|
||||
import_from_file: '从文件导入',
|
||||
back: '上一步',
|
||||
empty_diagram: '新建空关系图',
|
||||
empty_diagram: '空数据库',
|
||||
continue: '下一步',
|
||||
import: '导入',
|
||||
},
|
||||
|
||||
@@ -305,7 +305,7 @@ export const zh_TW: LanguageTranslation = {
|
||||
cancel: '取消',
|
||||
import_from_file: '從檔案匯入',
|
||||
back: '返回',
|
||||
empty_diagram: '空白圖表',
|
||||
empty_diagram: '空資料庫',
|
||||
continue: '繼續',
|
||||
import: '匯入',
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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), ''),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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','
|
||||
) +
|
||||
|
||||
356
src/lib/data/sql-export/__tests__/array-fields.test.ts
Normal file
356
src/lib/data/sql-export/__tests__/array-fields.test.ts
Normal 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[]');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
14
src/lib/dbml/dbml-export/__tests__/cases/6.dbml
Normal file
14
src/lib/dbml/dbml-export/__tests__/cases/6.dbml
Normal 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"
|
||||
1
src/lib/dbml/dbml-export/__tests__/cases/6.json
Normal file
1
src/lib/dbml/dbml-export/__tests__/cases/6.json
Normal 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":[]}
|
||||
205
src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts
Normal file
205
src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
317
src/lib/dbml/dbml-import/__tests__/dbml-array-fields.test.ts
Normal file
317
src/lib/dbml/dbml-import/__tests__/dbml-array-fields.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
57
src/lib/domain/database-capabilities.ts
Normal file
57
src/lib/domain/database-capabilities.ts
Normal 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;
|
||||
};
|
||||
@@ -9,9 +9,3 @@ export enum DatabaseType {
|
||||
COCKROACHDB = 'cockroachdb',
|
||||
ORACLE = 'oracle',
|
||||
}
|
||||
|
||||
export const databaseTypesWithCommentSupport: DatabaseType[] = [
|
||||
DatabaseType.POSTGRESQL,
|
||||
DatabaseType.COCKROACHDB,
|
||||
DatabaseType.ORACLE,
|
||||
];
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}</>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user