Compare commits

..

30 Commits

Author SHA1 Message Date
Guy Ben-Aharon
619cdc564c chore(main): release 1.17.0 2025-10-16 21:08:33 +03:00
Jonathan Fishner
459698b5d0 fix: add support for parsing default values in DBML (#948) 2025-10-16 21:07:55 +03:00
Guy Ben-Aharon
7ad0e7712d fix: manipulate schema directly from the canvas (#947) 2025-10-16 17:37:20 +03:00
Guy Ben-Aharon
34475add32 feat: create relationships on canvas modal (#946)
* feat: create relationships on canvas modal

* feat: create relationships on canvas modal

* feat: create relationships on canvas modal

* fix

* fix

* fix

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

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

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-10-09 15:43:41 +03:00
Jonathan Fishner
b15bc945ac fix: add timestampz and int as datatypes to postgres (#940) 2025-10-09 15:05:33 +03:00
Guy Ben-Aharon
c3c646bf7c fix: add rels export dbml (#937) 2025-09-28 20:27:41 +03:00
Jonathan Fishner
57b3b8777f fix: add auto-increment field detection in smart-query import (#935) 2025-09-28 17:09:02 +03:00
Guy Ben-Aharon
bb033091b1 fix: dbml diff fields types preview (#934) 2025-09-28 17:02:56 +03:00
Guy Ben-Aharon
c9ac8929c5 chore(main): release 1.16.0 (#881) 2025-09-27 15:57:34 +03:00
Guy Ben-Aharon
c567c0a5f3 fix: remove many to many rel option (#933) 2025-09-24 14:14:49 +03:00
Guy Ben-Aharon
2dc1a6fc75 fix: move area utils (#932) 2025-09-23 11:28:08 +03:00
Guy Ben-Aharon
98f6edd5c8 fix: add areas width and height + table width to diff check (#931)
* fix: add areas width and height + table width to diff check

* fix
2025-09-23 11:12:46 +03:00
Guy Ben-Aharon
47a7a73a13 fix: add tests for diff (#930)
* fix: add tests for diff

* fix
2025-09-21 18:34:29 +03:00
Guy Ben-Aharon
d71b46e8b5 move utils to dir (#929) 2025-09-21 16:58:12 +03:00
Guy Ben-Aharon
e4c4a3b354 fix: add diff x,y (#928) 2025-09-21 12:12:20 +03:00
Guy Ben-Aharon
1b8d51b73c fix: diff logic (#927) 2025-09-20 19:40:04 +03:00
Guy Ben-Aharon
93d72a896b fix: dbml edit mode glitch (#925) 2025-09-17 20:23:42 +03:00
Guy Ben-Aharon
9991077978 fix: handle bidirectional relationships in DBML export (#924) 2025-09-17 18:13:48 +03:00
Guy Ben-Aharon
bc82f9d6a8 fix: dbml export default time bug (#922) 2025-09-17 15:08:44 +03:00
Guy Ben-Aharon
26dc299cd2 fix: dbml export renaming fields bug (#921) 2025-09-17 14:27:53 +03:00
Guy Ben-Aharon
d6ba4a4074 fix: import dbml set pk field unique (#920) 2025-09-17 11:36:29 +03:00
Jonathan Fishner
d09379e8be feat: add area context menu and UI improvements (#918)
* feat: add area context menu and UI improvements

- Add right-click context menu for areas with edit/delete options
- Add pencil icon on hover for diagram name
- Add dynamic input width for diagram name
- Keep existing useClickAway behavior

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-09-16 14:57:38 +03:00
Guy Ben-Aharon
bdc41c0b74 fix: trigger edit table on canvas from context menu (#919) 2025-09-16 10:15:42 +03:00
Jonathan Fishner
d3dbf41894 fix(sqlite): improve parser to handle tables without column types and fix column detection (#914) 2025-09-15 20:50:44 +03:00
Jonathan Fishner
e6783a89cc refactor: remove dedicated DBML import dialog (#907)
* refactor: remove dedicated DBML import dialog and unify import flow through ImportDatabaseDialog

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-09-15 19:24:35 +03:00
Jonathan Fishner
af3638da7a feat(import-db): add DBML syntax to import database dialog (#768)
* feat(editor): add import DBML syntax in import database dialog

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-09-15 19:01:50 +03:00
Guy Ben-Aharon
8954d893bb feat: add quick table mode on canvas (#915)
* fix: add quick table mode on canvas

* fix

* fix

* fix
2025-09-14 20:42:01 +03:00
90 changed files with 7161 additions and 1998 deletions

View File

@@ -1,5 +1,64 @@
# Changelog
## [1.17.0](https://github.com/chartdb/chartdb/compare/v1.16.0...v1.17.0) (2025-10-16)
### Features
* create relationships on canvas modal ([#946](https://github.com/chartdb/chartdb/issues/946)) ([34475ad](https://github.com/chartdb/chartdb/commit/34475add32f11323589ef092ccf2a8e9152ff272))
### Bug Fixes
* add auto-increment field detection in smart-query import ([#935](https://github.com/chartdb/chartdb/issues/935)) ([57b3b87](https://github.com/chartdb/chartdb/commit/57b3b8777fd0a445abf0ba6603faab612d469d5c))
* add rels export dbml ([#937](https://github.com/chartdb/chartdb/issues/937)) ([c3c646b](https://github.com/chartdb/chartdb/commit/c3c646bf7cbb1328f4b2eb85c9a7e929f0fcd3b9))
* add support for parsing default values in DBML ([#948](https://github.com/chartdb/chartdb/issues/948)) ([459698b](https://github.com/chartdb/chartdb/commit/459698b5d0a1ff23a3719c2e55e4ab2e2384c4fe))
* add timestampz and int as datatypes to postgres ([#940](https://github.com/chartdb/chartdb/issues/940)) ([b15bc94](https://github.com/chartdb/chartdb/commit/b15bc945acb96d7cb3832b3b1b607dfcaef9e5ca))
* auto-enter edit mode when creating new tables from canvas ([#943](https://github.com/chartdb/chartdb/issues/943)) ([bcd8aa9](https://github.com/chartdb/chartdb/commit/bcd8aa9378aa563f40a2b6802cc503be4c882356))
* dbml diff fields types preview ([#934](https://github.com/chartdb/chartdb/issues/934)) ([bb03309](https://github.com/chartdb/chartdb/commit/bb033091b1f64b888822be1423a80f16f5314f6b))
* exit table edit on area click ([#945](https://github.com/chartdb/chartdb/issues/945)) ([38fedce](https://github.com/chartdb/chartdb/commit/38fedcec0c10ea2b3f0b7fc92ca1f5ac9e540389))
* manipulate schema directly from the canvas ([#947](https://github.com/chartdb/chartdb/issues/947)) ([7ad0e77](https://github.com/chartdb/chartdb/commit/7ad0e7712de975a23b2a337dc0a4a7fb4b122bd1))
* prevent text input glitch when editing table field names ([#944](https://github.com/chartdb/chartdb/issues/944)) ([498655e](https://github.com/chartdb/chartdb/commit/498655e7b77e57eaf641ba86263ce1ef60b93e16))
## [1.16.0](https://github.com/chartdb/chartdb/compare/v1.15.1...v1.16.0) (2025-09-24)
### Features
* add area context menu and UI improvements ([#918](https://github.com/chartdb/chartdb/issues/918)) ([d09379e](https://github.com/chartdb/chartdb/commit/d09379e8be0fa3c83ca77ff62ae815fe4db9869b))
* add quick table mode on canvas ([#915](https://github.com/chartdb/chartdb/issues/915)) ([8954d89](https://github.com/chartdb/chartdb/commit/8954d893bbfee45bb311380115fb14ebbf3a3133))
* add zoom navigation buttons to canvas filter for tables and areas ([#903](https://github.com/chartdb/chartdb/issues/903)) ([a0fb1ed](https://github.com/chartdb/chartdb/commit/a0fb1ed08ba18b66354fa3498d610097a83d4afc))
* **import-db:** add DBML syntax to import database dialog ([#768](https://github.com/chartdb/chartdb/issues/768)) ([af3638d](https://github.com/chartdb/chartdb/commit/af3638da7a9b70f281ceaddbc2f712a713d90cda))
### Bug Fixes
* add areas width and height + table width to diff check ([#931](https://github.com/chartdb/chartdb/issues/931)) ([98f6edd](https://github.com/chartdb/chartdb/commit/98f6edd5c8a8e9130e892b2d841744e0cf63a7bf))
* add diff x,y ([#928](https://github.com/chartdb/chartdb/issues/928)) ([e4c4a3b](https://github.com/chartdb/chartdb/commit/e4c4a3b35484d9ece955a5aec577603dde73d634))
* add support for ALTER TABLE ADD COLUMN in PostgreSQL importer ([#892](https://github.com/chartdb/chartdb/issues/892)) ([ec6e46f](https://github.com/chartdb/chartdb/commit/ec6e46fe81ea1806c179c50a4c5779d8596008aa))
* add tests for diff ([#930](https://github.com/chartdb/chartdb/issues/930)) ([47a7a73](https://github.com/chartdb/chartdb/commit/47a7a73a137b87dfa6e67aff5f939cf64ccf4601))
* dbml edit mode glitch ([#925](https://github.com/chartdb/chartdb/issues/925)) ([93d72a8](https://github.com/chartdb/chartdb/commit/93d72a896bab9aa79d8ea2f876126887e432214c))
* dbml export default time bug ([#922](https://github.com/chartdb/chartdb/issues/922)) ([bc82f9d](https://github.com/chartdb/chartdb/commit/bc82f9d6a8fe4de2f7e0fc465e0a20c5dbf8f41d))
* dbml export renaming fields bug ([#921](https://github.com/chartdb/chartdb/issues/921)) ([26dc299](https://github.com/chartdb/chartdb/commit/26dc299cd28e9890d191c13f84a15ac38ae48b11))
* **dbml:** export array fields without quotes ([#911](https://github.com/chartdb/chartdb/issues/911)) ([5e81c18](https://github.com/chartdb/chartdb/commit/5e81c1848aaa911990e1e881d62525f5254d6d34))
* diff logic ([#927](https://github.com/chartdb/chartdb/issues/927)) ([1b8d51b](https://github.com/chartdb/chartdb/commit/1b8d51b73c4ed4b7c5929adcb17a44927c7defca))
* export dbml issues after upgrade version ([#883](https://github.com/chartdb/chartdb/issues/883)) ([07937a2](https://github.com/chartdb/chartdb/commit/07937a2f51708b1c10b45c2bd1f9a9acf5c3f708))
* export sql + import metadata lib ([#902](https://github.com/chartdb/chartdb/issues/902)) ([ffddcdc](https://github.com/chartdb/chartdb/commit/ffddcdcc987bacb0e0d7e8dea27d08d3a8c5a8c8))
* handle bidirectional relationships in DBML export ([#924](https://github.com/chartdb/chartdb/issues/924)) ([9991077](https://github.com/chartdb/chartdb/commit/99910779789a9c6ef113d06bc3de31e35b9b04d1))
* import dbml set pk field unique ([#920](https://github.com/chartdb/chartdb/issues/920)) ([d6ba4a4](https://github.com/chartdb/chartdb/commit/d6ba4a40749d85d2703f120600df4345dab3c561))
* improve SQL default value parsing for PostgreSQL, MySQL, and SQL Server with proper type handling and casting support ([#900](https://github.com/chartdb/chartdb/issues/900)) ([fe9ef27](https://github.com/chartdb/chartdb/commit/fe9ef275b8619dcfd7e57541a62a6237a16d29a8))
* move area utils ([#932](https://github.com/chartdb/chartdb/issues/932)) ([2dc1a6f](https://github.com/chartdb/chartdb/commit/2dc1a6fc7519e0a455b0e1306601195deb156c96))
* move auto arrange to toolbar ([#904](https://github.com/chartdb/chartdb/issues/904)) ([b016a70](https://github.com/chartdb/chartdb/commit/b016a70691bc22af5720b4de683e8c9353994fcc))
* remove general db creation ([#901](https://github.com/chartdb/chartdb/issues/901)) ([df89f0b](https://github.com/chartdb/chartdb/commit/df89f0b6b9ba3fcc8b05bae4f60c0dc4ad1d2215))
* remove many to many rel option ([#933](https://github.com/chartdb/chartdb/issues/933)) ([c567c0a](https://github.com/chartdb/chartdb/commit/c567c0a5f39157b2c430e92192b6750304d7a834))
* reset increment and default when change field ([#896](https://github.com/chartdb/chartdb/issues/896)) ([e5e1d59](https://github.com/chartdb/chartdb/commit/e5e1d5932762422ea63acfd6cf9fe4f03aa822f7))
* **sql-import:** handle SQL Server DDL with multiple tables, inline foreign keys, and case-insensitive field matching ([#897](https://github.com/chartdb/chartdb/issues/897)) ([2a64dee](https://github.com/chartdb/chartdb/commit/2a64deebb87a11ee3892024c3273d682bb86f7ef))
* **sql-import:** support ALTER TABLE ALTER COLUMN TYPE in PostgreSQL importer ([#895](https://github.com/chartdb/chartdb/issues/895)) ([aa29061](https://github.com/chartdb/chartdb/commit/aa290615caf806d7d0374c848d50b4636fde7e96))
* **sqlite:** improve parser to handle tables without column types and fix column detection ([#914](https://github.com/chartdb/chartdb/issues/914)) ([d3dbf41](https://github.com/chartdb/chartdb/commit/d3dbf41894d74f0ffce9afe3bd810f065aa53017))
* trigger edit table on canvas from context menu ([#919](https://github.com/chartdb/chartdb/issues/919)) ([bdc41c0](https://github.com/chartdb/chartdb/commit/bdc41c0b74d9d9918e7b6cd2152fa07c0c58ce60))
* update deps vulns ([#909](https://github.com/chartdb/chartdb/issues/909)) ([2bd9ca2](https://github.com/chartdb/chartdb/commit/2bd9ca25b2c7b1f053ff4fdc8c5cfc1b0e65901d))
* upgrade dbml lib ([#880](https://github.com/chartdb/chartdb/issues/880)) ([d8e0bc7](https://github.com/chartdb/chartdb/commit/d8e0bc7db8881971ddaea7177bcebee13cc865f6))
## [1.15.1](https://github.com/chartdb/chartdb/compare/v1.15.0...v1.15.1) (2025-08-27)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "chartdb",
"version": "1.15.1",
"version": "1.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "chartdb",
"version": "1.15.1",
"version": "1.17.0",
"dependencies": {
"@ai-sdk/openai": "^0.0.51",
"@dbml/core": "^3.13.9",

View File

@@ -1,7 +1,7 @@
{
"name": "chartdb",
"private": true,
"version": "1.15.1",
"version": "1.17.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -38,7 +38,7 @@ export interface CodeSnippetProps {
className?: string;
code: string;
codeToCopy?: string;
language?: 'sql' | 'shell';
language?: 'sql' | 'shell' | 'dbml';
loading?: boolean;
autoScroll?: boolean;
isComplete?: boolean;

View File

@@ -9,12 +9,14 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'comment', foreground: '6A9955' }, // Comments
{ token: 'keyword', foreground: '569CD6' }, // Table, Ref keywords
{ token: 'string', foreground: 'CE9178' }, // Strings
{ token: 'annotation', foreground: '9CDCFE' }, // [annotations]
{ token: 'delimiter', foreground: 'D4D4D4' }, // Braces {}
{ token: 'operator', foreground: 'D4D4D4' }, // Operators
{ token: 'datatype', foreground: '4EC9B0' }, // Data types
{ token: 'type', foreground: '4EC9B0' }, // Data types
{ token: 'identifier', foreground: '9CDCFE' }, // Field names
],
colors: {},
});
@@ -23,12 +25,14 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
base: 'vs',
inherit: true,
rules: [
{ token: 'comment', foreground: '008000' }, // Comments
{ token: 'keyword', foreground: '0000FF' }, // Table, Ref keywords
{ token: 'string', foreground: 'A31515' }, // Strings
{ token: 'annotation', foreground: '001080' }, // [annotations]
{ token: 'delimiter', foreground: '000000' }, // Braces {}
{ token: 'operator', foreground: '000000' }, // Operators
{ token: 'type', foreground: '267F99' }, // Data types
{ token: 'identifier', foreground: '001080' }, // Field names
],
colors: {},
});
@@ -37,23 +41,59 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
const datatypePattern = dataTypesNames.join('|');
monaco.languages.setMonarchTokensProvider('dbml', {
keywords: ['Table', 'Ref', 'Indexes', 'Note', 'Enum'],
keywords: ['Table', 'Ref', 'Indexes', 'Note', 'Enum', 'enum'],
datatypes: dataTypesNames,
operators: ['>', '<', '-'],
tokenizer: {
root: [
// Comments
[/\/\/.*$/, 'comment'],
// Keywords - case insensitive
[
/\b([Tt][Aa][Bb][Ll][Ee]|[Ee][Nn][Uu][Mm]|[Rr][Ee][Ff]|[Ii][Nn][Dd][Ee][Xx][Ee][Ss]|[Nn][Oo][Tt][Ee])\b/,
'keyword',
],
// Annotations in brackets
[/\[.*?\]/, 'annotation'],
// Strings
[/'''/, 'string', '@tripleQuoteString'],
[/".*?"/, 'string'],
[/'.*?'/, 'string'],
[/"([^"\\]|\\.)*$/, 'string.invalid'], // non-terminated string
[/'([^'\\]|\\.)*$/, 'string.invalid'], // non-terminated string
[/"/, 'string', '@string_double'],
[/'/, 'string', '@string_single'],
[/`.*?`/, 'string'],
[/[{}]/, 'delimiter'],
[/[<>]/, 'operator'],
[new RegExp(`\\b(${datatypePattern})\\b`, 'i'), 'type'], // Added 'i' flag for case-insensitive matching
// Delimiters and operators
[/[{}()]/, 'delimiter'],
[/[<>-]/, 'operator'],
[/:/, 'delimiter'],
// Data types
[new RegExp(`\\b(${datatypePattern})\\b`, 'i'), 'type'],
// Numbers
[/\d+/, 'number'],
// Identifiers
[/[a-zA-Z_]\w*/, 'identifier'],
],
string_double: [
[/[^\\"]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, 'string', '@pop'],
],
string_single: [
[/[^\\']+/, 'string'],
[/\\./, 'string.escape'],
[/'/, 'string', '@pop'],
],
tripleQuoteString: [
[/[^']+/, 'string'],
[/'''/, 'string', '@pop'],

View File

@@ -11,12 +11,14 @@ export interface ColorPickerProps {
color: string;
onChange: (color: string) => void;
disabled?: boolean;
popoverOnMouseDown?: (e: React.MouseEvent) => void;
popoverOnClick?: (e: React.MouseEvent) => void;
}
export const ColorPicker = React.forwardRef<
React.ElementRef<typeof PopoverTrigger>,
ColorPickerProps
>(({ color, onChange, disabled }, ref) => {
>(({ color, onChange, disabled, popoverOnMouseDown, popoverOnClick }, ref) => {
return (
<Popover>
<PopoverTrigger
@@ -37,7 +39,11 @@ export const ColorPicker = React.forwardRef<
}}
/>
</PopoverTrigger>
<PopoverContent className="w-fit">
<PopoverContent
className="w-fit"
onMouseDown={popoverOnMouseDown}
onClick={popoverOnClick}
>
<div className="grid grid-cols-4 gap-2">
{colorOptions.map((option) => (
<div

View File

@@ -56,6 +56,9 @@ export interface SelectBoxProps {
popoverClassName?: string;
readonly?: boolean;
footerButtons?: React.ReactNode;
commandOnMouseDown?: (e: React.MouseEvent) => void;
commandOnClick?: (e: React.MouseEvent) => void;
onSearchChange?: (search: string) => void;
}
export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
@@ -83,6 +86,9 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
popoverClassName,
readonly,
footerButtons,
commandOnMouseDown,
commandOnClick,
onSearchChange,
},
ref
) => {
@@ -236,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(
@@ -243,6 +250,8 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
matches?.map((match) => match?.toString())
)
}
onMouseDown={commandOnMouseDown}
onClick={commandOnClick}
>
{multiple && (
<div
@@ -288,7 +297,15 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
</CommandItem>
);
},
[value, multiple, searchTerm, handleSelect, optionSuffix]
[
value,
multiple,
searchTerm,
handleSelect,
optionSuffix,
commandOnClick,
commandOnMouseDown,
]
);
return (
@@ -366,6 +383,8 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
popoverClassName
)}
align="center"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Command
filter={(value, search, keywords) => {
@@ -388,7 +407,10 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
<div className="relative">
<CommandInput
value={searchTerm}
onValueChange={(e) => setSearchTerm(e)}
onValueChange={(e) => {
setSearchTerm(e);
onSearchChange?.(e);
}}
ref={ref}
placeholder={inputPlaceholder ?? 'Search...'}
className="h-9"

View File

@@ -14,6 +14,41 @@ export interface CanvasContext {
overlapGraph: Graph<string>;
setShowFilter: React.Dispatch<React.SetStateAction<boolean>>;
showFilter: boolean;
editTableModeTable: {
tableId: string;
fieldId?: string;
} | null;
setEditTableModeTable: React.Dispatch<
React.SetStateAction<{
tableId: string;
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>({
@@ -23,4 +58,14 @@ export const canvasContext = createContext<CanvasContext>({
overlapGraph: createGraph(),
setShowFilter: emptyFn,
showFilter: false,
editTableModeTable: null,
setEditTableModeTable: emptyFn,
tempFloatingEdge: null,
setTempFloatingEdge: emptyFn,
startFloatingEdgeCreation: emptyFn,
endFloatingEdgeCreation: emptyFn,
hoveringTableId: null,
setHoveringTableId: emptyFn,
showCreateRelationshipNode: emptyFn,
hideCreateRelationshipNode: emptyFn,
});

View File

@@ -5,6 +5,7 @@ import React, {
useEffect,
useRef,
} from 'react';
import type { CanvasContext } from './canvas-context';
import { canvasContext } from './canvas-context';
import { useChartDB } from '@/hooks/use-chartdb';
import { adjustTablePositions } from '@/lib/domain/db-table';
@@ -15,6 +16,10 @@ import { createGraph } from '@/lib/graph';
import { useDiagramFilter } from '../diagram-filter-context/use-diagram-filter';
import { filterTable } from '@/lib/domain/diagram-filter/filter';
import { defaultSchemas } from '@/lib/data/default-schemas';
import {
CREATE_RELATIONSHIP_NODE_ID,
type CreateRelationshipNodeType,
} from '@/pages/editor-page/canvas/create-relationship-node/create-relationship-node';
interface CanvasProviderProps {
children: ReactNode;
@@ -30,11 +35,21 @@ 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<{
tableId: string;
fieldId?: string;
} | 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(() => {
@@ -118,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={{
@@ -127,6 +202,16 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
overlapGraph,
setShowFilter,
showFilter,
editTableModeTable,
setEditTableModeTable,
tempFloatingEdge: tempFloatingEdge,
setTempFloatingEdge: setTempFloatingEdge,
startFloatingEdgeCreation: startFloatingEdgeCreation,
endFloatingEdgeCreation: endFloatingEdgeCreation,
hoveringTableId,
setHoveringTableId,
showCreateRelationshipNode,
hideCreateRelationshipNode,
}}
>
{children}

View File

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

View File

@@ -7,7 +7,6 @@ import type { ExportImageDialogProps } from '@/dialogs/export-image-dialog/expor
import type { ExportDiagramDialogProps } from '@/dialogs/export-diagram-dialog/export-diagram-dialog';
import type { ImportDiagramDialogProps } from '@/dialogs/import-diagram-dialog/import-diagram-dialog';
import type { CreateRelationshipDialogProps } from '@/dialogs/create-relationship-dialog/create-relationship-dialog';
import type { ImportDBMLDialogProps } from '@/dialogs/import-dbml-dialog/import-dbml-dialog';
import type { OpenDiagramDialogProps } from '@/dialogs/open-diagram-dialog/open-diagram-dialog';
import type { CreateDiagramDialogProps } from '@/dialogs/create-diagram-dialog/create-diagram-dialog';
@@ -67,12 +66,6 @@ export interface DialogContext {
params: Omit<ImportDiagramDialogProps, 'dialog'>
) => void;
closeImportDiagramDialog: () => void;
// Import DBML dialog
openImportDBMLDialog: (
params?: Omit<ImportDBMLDialogProps, 'dialog'>
) => void;
closeImportDBMLDialog: () => void;
}
export const dialogContext = createContext<DialogContext>({
@@ -96,6 +89,4 @@ export const dialogContext = createContext<DialogContext>({
closeExportDiagramDialog: emptyFn,
openImportDiagramDialog: emptyFn,
closeImportDiagramDialog: emptyFn,
openImportDBMLDialog: emptyFn,
closeImportDBMLDialog: emptyFn,
});

View File

@@ -20,8 +20,6 @@ import type { ExportImageDialogProps } from '@/dialogs/export-image-dialog/expor
import { ExportImageDialog } from '@/dialogs/export-image-dialog/export-image-dialog';
import { ExportDiagramDialog } from '@/dialogs/export-diagram-dialog/export-diagram-dialog';
import { ImportDiagramDialog } from '@/dialogs/import-diagram-dialog/import-diagram-dialog';
import type { ImportDBMLDialogProps } from '@/dialogs/import-dbml-dialog/import-dbml-dialog';
import { ImportDBMLDialog } from '@/dialogs/import-dbml-dialog/import-dbml-dialog';
export const DialogProvider: React.FC<React.PropsWithChildren> = ({
children,
@@ -132,11 +130,6 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({
const [openImportDiagramDialog, setOpenImportDiagramDialog] =
useState(false);
// Import DBML dialog
const [openImportDBMLDialog, setOpenImportDBMLDialog] = useState(false);
const [importDBMLDialogParams, setImportDBMLDialogParams] =
useState<Omit<ImportDBMLDialogProps, 'dialog'>>();
return (
<dialogContext.Provider
value={{
@@ -165,11 +158,6 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({
openImportDiagramDialog: () => setOpenImportDiagramDialog(true),
closeImportDiagramDialog: () =>
setOpenImportDiagramDialog(false),
openImportDBMLDialog: (params) => {
setImportDBMLDialogParams(params);
setOpenImportDBMLDialog(true);
},
closeImportDBMLDialog: () => setOpenImportDBMLDialog(false),
}}
>
{children}
@@ -204,10 +192,6 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({
/>
<ExportDiagramDialog dialog={{ open: openExportDiagramDialog }} />
<ImportDiagramDialog dialog={{ open: openImportDiagramDialog }} />
<ImportDBMLDialog
dialog={{ open: openImportDBMLDialog }}
{...importDBMLDialogParams}
/>
</dialogContext.Provider>
);
};

View File

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

View File

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

View File

@@ -42,6 +42,14 @@ import {
type ValidationResult,
} from '@/lib/data/sql-import/sql-validator';
import { SQLValidationStatus } from './sql-validation-status';
import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language';
import type { ImportMethod } from '@/lib/import-method/import-method';
import { detectImportMethod } from '@/lib/import-method/detect-import-method';
import { verifyDBML } from '@/lib/dbml/dbml-import/verify-dbml';
import {
clearErrorHighlight,
highlightErrorLine,
} from '@/components/code-snippet/dbml/utils';
const calculateContentSizeMB = (content: string): number => {
return content.length / (1024 * 1024); // Convert to MB
@@ -55,49 +63,6 @@ const calculateIsLargeFile = (content: string): boolean => {
const errorScriptOutputMessage =
'Invalid JSON. Please correct it or contact us at support@chartdb.io for help.';
// Helper to detect if content is likely SQL DDL or JSON
const detectContentType = (content: string): 'query' | 'ddl' | null => {
if (!content || content.trim().length === 0) return null;
// Common SQL DDL keywords
const ddlKeywords = [
'CREATE TABLE',
'ALTER TABLE',
'DROP TABLE',
'CREATE INDEX',
'CREATE VIEW',
'CREATE PROCEDURE',
'CREATE FUNCTION',
'CREATE SCHEMA',
'CREATE DATABASE',
];
const upperContent = content.toUpperCase();
// Check for SQL DDL patterns
const hasDDLKeywords = ddlKeywords.some((keyword) =>
upperContent.includes(keyword)
);
if (hasDDLKeywords) return 'ddl';
// Check if it looks like JSON
try {
// Just check structure, don't need full parse for detection
if (
(content.trim().startsWith('{') && content.trim().endsWith('}')) ||
(content.trim().startsWith('[') && content.trim().endsWith(']'))
) {
return 'query';
}
} catch (error) {
// Not valid JSON, might be partial
console.error('Error detecting content type:', error);
}
// If we can't confidently detect, return null
return null;
};
export interface ImportDatabaseProps {
goBack?: () => void;
onImport: () => void;
@@ -111,8 +76,8 @@ export interface ImportDatabaseProps {
>;
keepDialogAfterImport?: boolean;
title: string;
importMethod: 'query' | 'ddl';
setImportMethod: (method: 'query' | 'ddl') => void;
importMethod: ImportMethod;
setImportMethod: (method: ImportMethod) => void;
}
export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
@@ -132,6 +97,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
const { effectiveTheme } = useTheme();
const [errorMessage, setErrorMessage] = useState('');
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const decorationsCollection = useRef<editor.IEditorDecorationsCollection>();
const pasteDisposableRef = useRef<IDisposable | null>(null);
const { t } = useTranslation();
@@ -146,15 +112,20 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
const [isAutoFixing, setIsAutoFixing] = useState(false);
const [showAutoFixButton, setShowAutoFixButton] = useState(false);
const clearDecorations = useCallback(() => {
clearErrorHighlight(decorationsCollection.current);
}, []);
useEffect(() => {
setScriptResult('');
setErrorMessage('');
setShowCheckJsonButton(false);
}, [importMethod, setScriptResult]);
// Check if the ddl is valid
// Check if the ddl or dbml is valid
useEffect(() => {
if (importMethod !== 'ddl') {
clearDecorations();
if (importMethod === 'query') {
setSqlValidation(null);
setShowAutoFixButton(false);
return;
@@ -163,9 +134,54 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
if (!scriptResult.trim()) {
setSqlValidation(null);
setShowAutoFixButton(false);
setErrorMessage('');
return;
}
if (importMethod === 'dbml') {
// Validate DBML by parsing it
const validateResponse = verifyDBML(scriptResult);
if (!validateResponse.hasError) {
setErrorMessage('');
setSqlValidation({
isValid: true,
errors: [],
warnings: [],
});
} else {
let errorMsg = 'Invalid DBML syntax';
let line: number = 1;
if (validateResponse.parsedError) {
errorMsg = validateResponse.parsedError.message;
line = validateResponse.parsedError.line;
highlightErrorLine({
error: validateResponse.parsedError,
model: editorRef.current?.getModel(),
editorDecorationsCollection:
decorationsCollection.current,
});
}
setSqlValidation({
isValid: false,
errors: [
{
message: errorMsg,
line: line,
type: 'syntax' as const,
},
],
warnings: [],
});
setErrorMessage(errorMsg);
}
setShowAutoFixButton(false);
return;
}
// SQL validation
// First run our validation based on database type
const validation = validateSQL(scriptResult, databaseType);
setSqlValidation(validation);
@@ -192,7 +208,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
setErrorMessage(result.error);
}
});
}, [importMethod, scriptResult, databaseType]);
}, [importMethod, scriptResult, databaseType, clearDecorations]);
// Check if the script result is a valid JSON
useEffect(() => {
@@ -320,6 +336,8 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
const handleEditorDidMount = useCallback(
(editor: editor.IStandaloneCodeEditor) => {
editorRef.current = editor;
decorationsCollection.current =
editor.createDecorationsCollection();
// Cleanup previous disposable if it exists
if (pasteDisposableRef.current) {
@@ -338,7 +356,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
const isLargeFile = calculateIsLargeFile(content);
// First, detect content type to determine if we should switch modes
const detectedType = detectContentType(content);
const detectedType = detectImportMethod(content);
if (detectedType && detectedType !== importMethod) {
// Switch to the detected mode immediately
setImportMethod(detectedType);
@@ -352,7 +370,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
?.run();
}, 100);
}
// For DDL mode, do NOT format as it can break the SQL
// For DDL and DBML modes, do NOT format as it can break the syntax
} else {
// Content type didn't change, apply formatting based on current mode
if (importMethod === 'query' && !isLargeFile) {
@@ -363,7 +381,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
?.run();
}, 100);
}
// For DDL mode or large files, do NOT format
// For DDL and DBML modes or large files, do NOT format
}
});
@@ -410,16 +428,25 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
<div className="w-full text-center text-xs text-muted-foreground">
{importMethod === 'query'
? 'Smart Query Output'
: 'SQL Script'}
: importMethod === 'dbml'
? 'DBML Script'
: 'SQL Script'}
</div>
<div className="flex-1 overflow-hidden">
<Suspense fallback={<Spinner />}>
<Editor
value={scriptResult}
onChange={debouncedHandleInputChange}
language={importMethod === 'query' ? 'json' : 'sql'}
language={
importMethod === 'query'
? 'json'
: importMethod === 'dbml'
? 'dbml'
: 'sql'
}
loading={<Spinner />}
onMount={handleEditorDidMount}
beforeMount={setupDBMLLanguage}
theme={
effectiveTheme === 'dark'
? 'dbml-dark'
@@ -430,7 +457,6 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
minimap: { enabled: false },
scrollBeyondLastLine: false,
automaticLayout: true,
glyphMargin: false,
lineNumbers: 'on',
guides: {
indentation: false,
@@ -455,7 +481,9 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
</Suspense>
</div>
{errorMessage || (importMethod === 'ddl' && sqlValidation) ? (
{errorMessage ||
((importMethod === 'ddl' || importMethod === 'dbml') &&
sqlValidation) ? (
<SQLValidationStatus
validation={sqlValidation}
errorMessage={errorMessage}

View File

@@ -15,9 +15,11 @@ import {
AvatarImage,
} from '@/components/avatar/avatar';
import { useTranslation } from 'react-i18next';
import { Code } from 'lucide-react';
import { Code, FileCode } from 'lucide-react';
import { SmartQueryInstructions } from './instructions/smart-query-instructions';
import { DDLInstructions } from './instructions/ddl-instructions';
import { DBMLInstructions } from './instructions/dbml-instructions';
import type { ImportMethod } from '@/lib/import-method/import-method';
const DatabasesWithoutDDLInstructions: DatabaseType[] = [
DatabaseType.CLICKHOUSE,
@@ -30,8 +32,8 @@ export interface InstructionsSectionProps {
setDatabaseEdition: React.Dispatch<
React.SetStateAction<DatabaseEdition | undefined>
>;
importMethod: 'query' | 'ddl';
setImportMethod: (method: 'query' | 'ddl') => void;
importMethod: ImportMethod;
setImportMethod: (method: ImportMethod) => void;
showSSMSInfoDialog: boolean;
setShowSSMSInfoDialog: (show: boolean) => void;
}
@@ -125,9 +127,9 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
className="ml-1 flex-wrap justify-start gap-2"
value={importMethod}
onValueChange={(value) => {
let selectedImportMethod: 'query' | 'ddl' = 'query';
let selectedImportMethod: ImportMethod = 'query';
if (value) {
selectedImportMethod = value as 'query' | 'ddl';
selectedImportMethod = value as ImportMethod;
}
setImportMethod(selectedImportMethod);
@@ -150,10 +152,20 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
>
<Avatar className="size-4 rounded-none">
<Code size={16} />
<FileCode size={16} />
</Avatar>
SQL Script
</ToggleGroupItem>
<ToggleGroupItem
value="dbml"
variant="outline"
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
>
<Avatar className="size-4 rounded-none">
<Code size={16} />
</Avatar>
DBML
</ToggleGroupItem>
</ToggleGroup>
</div>
)}
@@ -167,11 +179,16 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
showSSMSInfoDialog={showSSMSInfoDialog}
setShowSSMSInfoDialog={setShowSSMSInfoDialog}
/>
) : (
) : importMethod === 'ddl' ? (
<DDLInstructions
databaseType={databaseType}
databaseEdition={databaseEdition}
/>
) : (
<DBMLInstructions
databaseType={databaseType}
databaseEdition={databaseEdition}
/>
)}
</div>
</div>

View File

@@ -0,0 +1,47 @@
import React from 'react';
import type { DatabaseType } from '@/lib/domain/database-type';
import type { DatabaseEdition } from '@/lib/domain/database-edition';
import { CodeSnippet } from '@/components/code-snippet/code-snippet';
import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language';
export interface DBMLInstructionsProps {
databaseType: DatabaseType;
databaseEdition?: DatabaseEdition;
}
export const DBMLInstructions: React.FC<DBMLInstructionsProps> = () => {
return (
<>
<div className="flex flex-col gap-1 text-sm text-primary">
<div>
Paste your DBML (Database Markup Language) schema definition
here
</div>
</div>
<div className="flex h-64 flex-col gap-1 text-sm text-primary">
<h4 className="text-xs font-medium">Example:</h4>
<CodeSnippet
className="h-full"
allowCopy={false}
editorProps={{
beforeMount: setupDBMLLanguage,
}}
code={`Table users {
id int [pk]
username varchar
email varchar
}
Table posts {
id int [pk]
user_id int [ref: > users.id]
title varchar
content text
}`}
language={'dbml'}
/>
</div>
</>
);
};

View File

@@ -43,8 +43,8 @@ const DDLInstructionsMap: Record<DatabaseType, DDLInstruction[]> = {
},
{
text: 'Execute the following command in your terminal:',
code: `sqlite3 <database_file_path>\n.dump > <output_file_path>`,
example: `sqlite3 my_db.db\n.dump > schema_export.sql`,
code: `sqlite3 <database_file_path>\n".schema" > <output_file_path>`,
example: `sqlite3 my_db.db\n".schema" > schema_export.sql`,
},
{
text: 'Open the exported SQL file, copy its contents, and paste them here.',

View File

@@ -57,36 +57,11 @@ export const SQLValidationStatus: React.FC<SQLValidationStatusProps> = ({
// If we have parser errors (errorMessage) after validation
if (errorMessage && !hasErrors) {
// Check if the error is related to parsing issues
const isParsingError =
errorMessage.toLowerCase().includes('error parsing') ||
errorMessage.toLowerCase().includes('unexpected');
return (
<>
<Separator className="mb-1 mt-2" />
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950">
<div className="space-y-3 p-3 pt-2 text-red-700 dark:text-red-300">
<div className="flex items-start gap-2">
<MessageCircleWarning className="mt-0.5 size-4 shrink-0 text-red-700 dark:text-red-300" />
<div className="flex-1 text-sm text-red-700 dark:text-red-300">
<div className="font-medium">
{isParsingError
? 'SQL Parsing Failed'
: 'SQL Import Error'}
</div>
<div className="mt-1 text-xs">
{errorMessage}
</div>
{isParsingError && (
<div className="mt-2 text-xs opacity-90">
This may indicate incompatible SQL
syntax for the selected database type.
</div>
)}
</div>
</div>
</div>
<div className="mb-1 flex shrink-0 items-center gap-2">
<p className="text-xs text-red-700">{errorMessage}</p>
</div>
</>
);
@@ -98,7 +73,7 @@ export const SQLValidationStatus: React.FC<SQLValidationStatusProps> = ({
{hasErrors ? (
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950">
<ScrollArea className="h-24">
<ScrollArea className="h-fit max-h-24">
<div className="space-y-3 p-3 pt-2 text-red-700 dark:text-red-300">
{validation?.errors
.slice(0, 3)
@@ -162,7 +137,7 @@ export const SQLValidationStatus: React.FC<SQLValidationStatusProps> = ({
{hasWarnings && !hasErrors ? (
<div className="rounded-md border border-sky-200 bg-sky-50 dark:border-sky-800 dark:bg-sky-950">
<ScrollArea className="h-24">
<ScrollArea className="h-fit max-h-24">
<div className="space-y-3 p-3 pt-2 text-sky-700 dark:text-sky-300">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-sky-700 dark:text-sky-300" />

View File

@@ -22,6 +22,11 @@ import { sqlImportToDiagram } from '@/lib/data/sql-import';
import type { SelectedTable } from '@/lib/data/import-metadata/filter-metadata';
import { filterMetadataByTables } from '@/lib/data/import-metadata/filter-metadata';
import { MAX_TABLES_WITHOUT_SHOWING_FILTER } from '../common/select-tables/constants';
import {
defaultDBMLDiagramName,
importDBMLToDiagram,
} from '@/lib/dbml/dbml-import/dbml-import';
import type { ImportMethod } from '@/lib/import-method/import-method';
export interface CreateDiagramDialogProps extends BaseDialogProps {}
@@ -30,11 +35,11 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
}) => {
const { diagramId } = useChartDB();
const { t } = useTranslation();
const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query');
const [importMethod, setImportMethod] = useState<ImportMethod>('query');
const [databaseType, setDatabaseType] = useState<DatabaseType>(
DatabaseType.GENERIC
);
const { closeCreateDiagramDialog, openImportDBMLDialog } = useDialog();
const { closeCreateDiagramDialog } = useDialog();
const { updateConfig } = useConfig();
const [scriptResult, setScriptResult] = useState('');
const [databaseEdition, setDatabaseEdition] = useState<
@@ -89,6 +94,14 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
sourceDatabaseType: databaseType,
targetDatabaseType: databaseType,
});
} else if (importMethod === 'dbml') {
diagram = await importDBMLToDiagram(scriptResult, {
databaseType,
});
// Update the diagram name if it's the default
if (diagram.name === defaultDBMLDiagramName) {
diagram.name = `Diagram ${diagramNumber}`;
}
} else {
let metadata: DatabaseMetadata | undefined = databaseMetadata;
@@ -152,10 +165,6 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
await updateConfig({ config: { defaultDiagramId: diagram.id } });
closeCreateDiagramDialog();
navigate(`/diagrams/${diagram.id}`);
setTimeout(
() => openImportDBMLDialog({ withCreateEmptyDiagram: true }),
700
);
}, [
databaseType,
addDiagram,
@@ -164,14 +173,13 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
navigate,
updateConfig,
diagramNumber,
openImportDBMLDialog,
]);
const importNewDiagramOrFilterTables = useCallback(async () => {
try {
setIsParsingMetadata(true);
if (importMethod === 'ddl') {
if (importMethod === 'ddl' || importMethod === 'dbml') {
await importNewDiagram();
} else {
// Parse metadata asynchronously to avoid blocking the UI

View File

@@ -15,6 +15,8 @@ import { useReactFlow } from '@xyflow/react';
import type { BaseDialogProps } from '../common/base-dialog-props';
import { useAlert } from '@/context/alert-context/alert-context';
import { sqlImportToDiagram } from '@/lib/data/sql-import';
import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import';
import type { ImportMethod } from '@/lib/import-method/import-method';
export interface ImportDatabaseDialogProps extends BaseDialogProps {
databaseType: DatabaseType;
@@ -24,7 +26,7 @@ export const ImportDatabaseDialog: React.FC<ImportDatabaseDialogProps> = ({
dialog,
databaseType,
}) => {
const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query');
const [importMethod, setImportMethod] = useState<ImportMethod>('query');
const { closeImportDatabaseDialog } = useDialog();
const { showAlert } = useAlert();
const {
@@ -65,6 +67,10 @@ export const ImportDatabaseDialog: React.FC<ImportDatabaseDialogProps> = ({
sourceDatabaseType: databaseType,
targetDatabaseType: databaseType,
});
} else if (importMethod === 'dbml') {
diagram = await importDBMLToDiagram(scriptResult, {
databaseType,
});
} else {
const databaseMetadata: DatabaseMetadata =
loadDatabaseMetadata(scriptResult);

View File

@@ -1,359 +0,0 @@
import React, {
useCallback,
useEffect,
useState,
Suspense,
useRef,
} from 'react';
import type * as monaco from 'monaco-editor';
import { useDialog } from '@/hooks/use-dialog';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogInternalContent,
DialogTitle,
} from '@/components/dialog/dialog';
import { Button } from '@/components/button/button';
import type { BaseDialogProps } from '../common/base-dialog-props';
import { useTranslation } from 'react-i18next';
import { Editor } from '@/components/code-snippet/code-snippet';
import { useTheme } from '@/hooks/use-theme';
import { AlertCircle } from 'lucide-react';
import {
importDBMLToDiagram,
sanitizeDBML,
preprocessDBML,
} from '@/lib/dbml/dbml-import/dbml-import';
import { useChartDB } from '@/hooks/use-chartdb';
import { Parser } from '@dbml/core';
import { useCanvas } from '@/hooks/use-canvas';
import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language';
import type { DBTable } from '@/lib/domain/db-table';
import { useToast } from '@/components/toast/use-toast';
import { Spinner } from '@/components/spinner/spinner';
import { debounce } from '@/lib/utils';
import { parseDBMLError } from '@/lib/dbml/dbml-import/dbml-import-error';
import {
clearErrorHighlight,
highlightErrorLine,
} from '@/components/code-snippet/dbml/utils';
export interface ImportDBMLDialogProps extends BaseDialogProps {
withCreateEmptyDiagram?: boolean;
}
export const ImportDBMLDialog: React.FC<ImportDBMLDialogProps> = ({
dialog,
withCreateEmptyDiagram,
}) => {
const { t } = useTranslation();
const initialDBML = `// Use DBML to define your database structure
// Simple Blog System with Comments Example
Table users {
id integer [primary key]
name varchar
email varchar
}
Table posts {
id integer [primary key]
title varchar
content text
user_id integer
created_at timestamp
}
Table comments {
id integer [primary key]
content text
post_id integer
user_id integer
created_at timestamp
}
// Relationships
Ref: posts.user_id > users.id // Each post belongs to one user
Ref: comments.post_id > posts.id // Each comment belongs to one post
Ref: comments.user_id > users.id // Each comment is written by one user`;
const [dbmlContent, setDBMLContent] = useState<string>(initialDBML);
const { closeImportDBMLDialog } = useDialog();
const [errorMessage, setErrorMessage] = useState<string | undefined>();
const { effectiveTheme } = useTheme();
const { toast } = useToast();
const {
addTables,
addRelationships,
tables,
relationships,
removeTables,
removeRelationships,
} = useChartDB();
const { reorderTables } = useCanvas();
const [reorder, setReorder] = useState(false);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();
const decorationsCollection =
useRef<monaco.editor.IEditorDecorationsCollection>();
const handleEditorDidMount = (
editor: monaco.editor.IStandaloneCodeEditor
) => {
editorRef.current = editor;
decorationsCollection.current = editor.createDecorationsCollection();
};
useEffect(() => {
if (reorder) {
reorderTables({
updateHistory: false,
});
setReorder(false);
}
}, [reorder, reorderTables]);
const clearDecorations = useCallback(() => {
clearErrorHighlight(decorationsCollection.current);
}, []);
const validateDBML = useCallback(
async (content: string) => {
// Clear previous errors
setErrorMessage(undefined);
clearDecorations();
if (!content.trim()) return;
try {
const preprocessedContent = preprocessDBML(content);
const sanitizedContent = sanitizeDBML(preprocessedContent);
const parser = new Parser();
parser.parse(sanitizedContent, 'dbmlv2');
} catch (e) {
const parsedError = parseDBMLError(e);
if (parsedError) {
setErrorMessage(
t('import_dbml_dialog.error.description') +
` (1 error found - in line ${parsedError.line})`
);
highlightErrorLine({
error: parsedError,
model: editorRef.current?.getModel(),
editorDecorationsCollection:
decorationsCollection.current,
});
} else {
setErrorMessage(
e instanceof Error ? e.message : JSON.stringify(e)
);
}
}
},
[clearDecorations, t]
);
const debouncedValidateRef = useRef<((value: string) => void) | null>(null);
// Set up debounced validation
useEffect(() => {
debouncedValidateRef.current = debounce((value: string) => {
validateDBML(value);
}, 500);
return () => {
debouncedValidateRef.current = null;
};
}, [validateDBML]);
// Trigger validation when content changes
useEffect(() => {
if (debouncedValidateRef.current) {
debouncedValidateRef.current(dbmlContent);
}
}, [dbmlContent]);
useEffect(() => {
if (!dialog.open) {
setErrorMessage(undefined);
clearDecorations();
setDBMLContent(initialDBML);
}
}, [dialog.open, initialDBML, clearDecorations]);
const handleImport = useCallback(async () => {
if (!dbmlContent.trim() || errorMessage) return;
try {
const importedDiagram = await importDBMLToDiagram(dbmlContent);
const tableIdsToRemove = tables
.filter((table) =>
importedDiagram.tables?.some(
(t: DBTable) =>
t.name === table.name && t.schema === table.schema
)
)
.map((table) => table.id);
// Find relationships that need to be removed
const relationshipIdsToRemove = relationships
.filter((relationship) => {
const sourceTable = tables.find(
(table: DBTable) =>
table.id === relationship.sourceTableId
);
const targetTable = tables.find(
(table: DBTable) =>
table.id === relationship.targetTableId
);
if (!sourceTable || !targetTable) return true;
const replacementSourceTable = importedDiagram.tables?.find(
(table: DBTable) =>
table.name === sourceTable.name &&
table.schema === sourceTable.schema
);
const replacementTargetTable = importedDiagram.tables?.find(
(table: DBTable) =>
table.name === targetTable.name &&
table.schema === targetTable.schema
);
return replacementSourceTable || replacementTargetTable;
})
.map((relationship) => relationship.id);
// Remove existing items
await Promise.all([
removeTables(tableIdsToRemove, { updateHistory: false }),
removeRelationships(relationshipIdsToRemove, {
updateHistory: false,
}),
]);
// Add new items
await Promise.all([
addTables(importedDiagram.tables ?? [], {
updateHistory: false,
}),
addRelationships(importedDiagram.relationships ?? [], {
updateHistory: false,
}),
]);
setReorder(true);
closeImportDBMLDialog();
} catch (e) {
toast({
title: t('import_dbml_dialog.error.title'),
variant: 'destructive',
description: (
<>
<div>{t('import_dbml_dialog.error.description')}</div>
{e instanceof Error ? e.message : JSON.stringify(e)}
</>
),
});
}
}, [
dbmlContent,
closeImportDBMLDialog,
tables,
relationships,
removeTables,
removeRelationships,
addTables,
addRelationships,
errorMessage,
toast,
setReorder,
t,
]);
return (
<Dialog
{...dialog}
onOpenChange={(open) => {
if (!open) {
closeImportDBMLDialog();
}
}}
>
<DialogContent
className="flex h-[80vh] max-h-screen w-full flex-col md:max-w-[900px]"
showClose
>
<DialogHeader>
<DialogTitle>
{withCreateEmptyDiagram
? t('import_dbml_dialog.example_title')
: t('import_dbml_dialog.title')}
</DialogTitle>
<DialogDescription>
{t('import_dbml_dialog.description')}
</DialogDescription>
</DialogHeader>
<DialogInternalContent>
<Suspense fallback={<Spinner />}>
<Editor
value={dbmlContent}
onChange={(value) => setDBMLContent(value || '')}
language="dbml"
onMount={handleEditorDidMount}
theme={
effectiveTheme === 'dark'
? 'dbml-dark'
: 'dbml-light'
}
beforeMount={setupDBMLLanguage}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
automaticLayout: true,
glyphMargin: true,
lineNumbers: 'on',
scrollbar: {
vertical: 'visible',
horizontal: 'visible',
},
}}
className="size-full"
/>
</Suspense>
</DialogInternalContent>
<DialogFooter>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-4">
<DialogClose asChild>
<Button variant="secondary">
{withCreateEmptyDiagram
? t('import_dbml_dialog.skip_and_empty')
: t('import_dbml_dialog.cancel')}
</Button>
</DialogClose>
{errorMessage ? (
<div className="flex items-center gap-1">
<AlertCircle className="size-4 text-destructive" />
<span className="text-xs text-destructive">
{errorMessage ||
t(
'import_dbml_dialog.error.description'
)}
</span>
</div>
) : null}
</div>
<Button
onClick={handleImport}
disabled={!dbmlContent.trim() || !!errorMessage}
>
{withCreateEmptyDiagram
? t('import_dbml_dialog.show_example')
: t('import_dbml_dialog.import')}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,328 @@
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 {
SelectBoxOption,
SelectBoxProps,
} from '@/components/select-box/select-box';
import {
dataTypeDataToDataType,
sortedDataTypeMap,
} 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
): {
regex?: string;
extractRegex?: RegExp;
} => {
if (!dataType.fieldAttributes) {
return { regex: undefined, extractRegex: undefined };
}
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,
};
}
return {
regex: `^${typeName}\\(\\d+\\)$`,
extractRegex: /\((\d+)\)/,
};
}
if (fieldAttributes.precision && fieldAttributes.scale) {
return {
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)$`,
extractRegex: new RegExp(
`${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)`
),
};
}
if (fieldAttributes.precision) {
return {
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)$`,
extractRegex: /\((\d+)\)/,
};
}
return { regex: undefined, extractRegex: undefined };
};
export const useUpdateTableField = (
table: DBTable,
field: DBField,
customUpdateField?: (attrs: Partial<DBField>) => void
) => {
const {
databaseType,
customTypes,
updateField: chartDBUpdateField,
removeField: chartDBRemoveField,
} = useChartDB();
// Local state for responsive UI
const [localFieldName, setLocalFieldName] = useState(field.name);
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(() => {
setLocalNullable(field.nullable);
setLocalPrimaryKey(field.primaryKey);
}, [field.nullable, field.primaryKey]);
// Use custom updateField if provided, otherwise use the chartDB one
const updateField = useMemo(
() =>
customUpdateField
? (
_tableId: string,
_fieldId: string,
attrs: Partial<DBField>
) => customUpdateField(attrs)
: chartDBUpdateField,
[customUpdateField, chartDBUpdateField]
);
// Calculate primary key fields for validation
const primaryKeyFields = useMemo(() => {
return table.fields.filter((f) => f.primaryKey);
}, [table.fields]);
const primaryKeyCount = useMemo(
() => primaryKeyFields.length,
[primaryKeyFields.length]
);
// Generate data type options for select box
const dataFieldOptions = useMemo(() => {
const standardTypes: SelectBoxOption[] = sortedDataTypeMap[
databaseType
].map((type) => {
const regexPatterns = generateFieldRegexPatterns(type);
return {
label: type.name,
value: type.id,
regex: regexPatterns.regex,
extractRegex: regexPatterns.extractRegex,
group: customTypes?.length ? 'Standard Types' : undefined,
};
});
if (!customTypes?.length) {
return standardTypes;
}
// Add custom types as options
const customTypeOptions: SelectBoxOption[] = customTypes.map(
(type) => ({
label: type.name,
value: type.name,
description:
type.kind === 'enum' ? `${type.values?.join(' | ')}` : '',
group: 'Custom Types',
})
);
return [...standardTypes, ...customTypeOptions];
}, [databaseType, customTypes]);
// Handle data type change
const handleDataTypeChange = useCallback<
NonNullable<SelectBoxProps['onChange']>
>(
(value, regexMatches) => {
const dataType = sortedDataTypeMap[databaseType].find(
(v) => v.id === value
) ?? {
id: value as string,
name: value as string,
};
let characterMaximumLength: string | undefined = undefined;
let precision: number | undefined = undefined;
let scale: number | undefined = undefined;
if (regexMatches?.length) {
if (dataType?.fieldAttributes?.hasCharMaxLength) {
characterMaximumLength = regexMatches[1]?.toLowerCase();
} else if (
dataType?.fieldAttributes?.precision &&
dataType?.fieldAttributes?.scale
) {
precision = parseInt(regexMatches[1]);
scale = regexMatches[2]
? parseInt(regexMatches[2])
: undefined;
} else if (dataType?.fieldAttributes?.precision) {
precision = parseInt(regexMatches[1]);
}
} else {
if (
dataType?.fieldAttributes?.hasCharMaxLength &&
field.characterMaximumLength
) {
characterMaximumLength = field.characterMaximumLength;
}
if (dataType?.fieldAttributes?.precision && field.precision) {
precision = field.precision;
}
if (dataType?.fieldAttributes?.scale && field.scale) {
scale = field.scale;
}
}
updateField(table.id, field.id, {
characterMaximumLength,
precision,
scale,
increment: undefined,
default: undefined,
type: dataTypeDataToDataType(
dataType ?? {
id: value as string,
name: value as string,
}
),
});
},
[
updateField,
databaseType,
field.characterMaximumLength,
field.precision,
field.scale,
field.id,
table.id,
]
);
// Debounced update for field name
const debouncedNameUpdate = useDebounce(
useCallback(
(value: string) => {
if (value.trim() !== field.name) {
updateField(table.id, field.id, { name: value });
}
},
[updateField, table.id, field.id, field.name]
),
300 // 300ms debounce for text input
);
// Debounced update for nullable toggle
const debouncedNullableUpdate = useDebounce(
useCallback(
(value: boolean) => {
updateField(table.id, field.id, { nullable: value });
},
[updateField, table.id, field.id]
),
100 // 100ms debounce for toggle
);
// Debounced update for primary key toggle
const debouncedPrimaryKeyUpdate = useDebounce(
useCallback(
(value: boolean, primaryKeyCount: number) => {
if (value) {
// When setting as primary key
const updates: Partial<DBField> = {
primaryKey: true,
};
// Only auto-set unique if this will be the only primary key
if (primaryKeyCount === 0) {
updates.unique = true;
}
updateField(table.id, field.id, updates);
} else {
// When removing primary key
updateField(table.id, field.id, {
primaryKey: false,
});
}
},
[updateField, table.id, field.id]
),
100 // 100ms debounce for toggle
);
// Handle primary key toggle with optimistic update
const handlePrimaryKeyToggle = useCallback(
(value: boolean) => {
setLocalPrimaryKey(value);
debouncedPrimaryKeyUpdate(value, primaryKeyCount);
},
[primaryKeyCount, debouncedPrimaryKeyUpdate]
);
// Handle nullable toggle with optimistic update
const handleNullableToggle = useCallback(
(value: boolean) => {
setLocalNullable(value);
debouncedNullableUpdate(value);
},
[debouncedNullableUpdate]
);
// Handle name change with optimistic update
const handleNameChange = useCallback(
(value: string) => {
setLocalFieldName(value);
debouncedNameUpdate(value);
},
[debouncedNameUpdate]
);
// Utility function to generate field suffix for display
const generateFieldSuffix = useCallback(
(typeId?: string) => {
return generateDBFieldSuffix(field, {
databaseType,
forceExtended: true,
typeId,
});
},
[field, databaseType]
);
const removeField = useCallback(() => {
chartDBRemoveField(table.id, field.id);
}, [chartDBRemoveField, table.id, field.id]);
return {
dataFieldOptions,
handleDataTypeChange,
handlePrimaryKeyToggle,
handleNullableToggle,
handleNameChange,
generateFieldSuffix,
primaryKeyCount,
fieldName: localFieldName,
nullable: localNullable,
primaryKey: localPrimaryKey,
removeField,
};
};

View File

@@ -0,0 +1,42 @@
import { useCallback, useState, useEffect } from 'react';
import { useChartDB } from './use-chartdb';
import { useDebounce } from './use-debounce-v2';
import type { DBTable } from '@/lib/domain';
// Hook for updating table properties with debouncing for performance
export const useUpdateTable = (table: DBTable) => {
const { updateTable: chartDBUpdateTable } = useChartDB();
const [localTableName, setLocalTableName] = useState(table.name);
// Debounced update function
const debouncedUpdate = useDebounce(
useCallback(
(value: string) => {
if (value.trim() && value.trim() !== table.name) {
chartDBUpdateTable(table.id, { name: value.trim() });
}
},
[chartDBUpdateTable, table.id, table.name]
),
1000 // 1000ms debounce
);
// Update local state immediately for responsive UI
const handleTableNameChange = useCallback(
(value: string) => {
setLocalTableName(value);
debouncedUpdate(value);
},
[debouncedUpdate]
);
// Update local state when table name changes externally
useEffect(() => {
setLocalTableName(table.name);
}, [table.name]);
return {
tableName: localTableName,
handleTableNameChange,
};
};

View File

@@ -18,4 +18,7 @@
.marker-definitions {
}
.nodrag {
}
}

View File

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

View File

@@ -57,6 +57,9 @@ 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 }
: {}),
createdAt: Date.now(),
comments: col.comment ? col.comment : undefined,
})

View File

@@ -15,6 +15,7 @@ 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
}
export const ColumnInfoSchema: z.ZodType<ColumnInfo> = z.object({
@@ -35,4 +36,5 @@ 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(),
});

View File

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

View File

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

View File

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

View File

@@ -194,7 +194,12 @@ 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,
'}')), ',') AS cols_metadata
FROM information_schema.columns cols
LEFT JOIN pg_catalog.pg_class c
ON c.relname = cols.table_name

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import {
databaseTypesWithCommentSupport,
} from '@/lib/domain/database-type';
import type { DBTable } from '@/lib/domain/db-table';
import type { DataType } from '../data-types/data-types';
import { dataTypeMap, type DataType } from '../data-types/data-types';
import { generateCacheKey, getFromCache, setInCache } from './export-sql-cache';
import { exportMSSQL } from './export-per-type/mssql';
import { exportPostgreSQL } from './export-per-type/postgresql';
@@ -13,6 +13,55 @@ import { exportSQLite } from './export-per-type/sqlite';
import { exportMySQL } from './export-per-type/mysql';
import { escapeSQLComment } from './export-per-type/common';
// 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 => {
const typeMap: Record<string, string> = {};
@@ -314,11 +363,26 @@ export const exportBaseSQL = ({
sqlScript += `(1)`;
}
// Add precision and scale for numeric types
if (field.precision && field.scale) {
sqlScript += `(${field.precision}, ${field.scale})`;
} else if (field.precision) {
sqlScript += `(${field.precision})`;
// Add precision and scale for numeric types only
const precisionAndScaleTypes = dataTypeMap[targetDatabaseType]
.filter(
(t) =>
t.fieldAttributes?.precision && t.fieldAttributes?.scale
)
.map((t) => t.name);
const isNumericType = precisionAndScaleTypes.some(
(t) =>
field.type.name.toLowerCase().includes(t) ||
typeName.toLowerCase().includes(t)
);
if (isNumericType) {
if (field.precision && field.scale) {
sqlScript += `(${field.precision}, ${field.scale})`;
} else if (field.precision) {
sqlScript += `(${field.precision})`;
}
}
// Handle NOT NULL constraint
@@ -366,7 +430,19 @@ export const exportBaseSQL = ({
fieldDefault = `now()`;
}
sqlScript += ` DEFAULT ${fieldDefault}`;
// Fix CURRENT_DATE() for PostgreSQL in DBML flow - PostgreSQL uses CURRENT_DATE without parentheses
if (
isDBMLFlow &&
targetDatabaseType === DatabaseType.POSTGRESQL
) {
if (fieldDefault.toUpperCase() === 'CURRENT_DATE()') {
fieldDefault = 'CURRENT_DATE';
}
}
// Format default value with proper quoting
const formattedDefault = formatDefaultValue(fieldDefault);
sqlScript += ` DEFAULT ${formattedDefault}`;
}
}
@@ -454,10 +530,16 @@ export const exportBaseSQL = ({
.join(', ');
if (fieldNames) {
const indexName =
const rawIndexName =
table.schema && !isDBMLFlow
? `${table.schema}_${index.name}`
: index.name;
// Quote index name if it contains special characters
// For DBML flow, also quote if contains special characters
const needsQuoting = /[^a-zA-Z0-9_]/.test(rawIndexName);
const indexName = needsQuoting
? `"${rawIndexName}"`
: rawIndexName;
sqlScript += `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName} ON ${tableName} (${fieldNames});\n`;
}
});

View File

@@ -1,66 +0,0 @@
import { describe, it } from 'vitest';
describe('node-sql-parser - CREATE TYPE handling', () => {
it('should show exact parser error for CREATE TYPE', async () => {
const { Parser } = await import('node-sql-parser');
const parser = new Parser();
const parserOpts = {
database: 'PostgreSQL',
};
console.log('\n=== Testing CREATE TYPE statement ===');
const createTypeSQL = `CREATE TYPE spell_element AS ENUM ('fire', 'water', 'earth', 'air');`;
try {
parser.astify(createTypeSQL, parserOpts);
console.log('CREATE TYPE parsed successfully');
} catch (error) {
console.log('CREATE TYPE parse error:', (error as Error).message);
}
console.log('\n=== Testing CREATE EXTENSION statement ===');
const createExtensionSQL = `CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`;
try {
parser.astify(createExtensionSQL, parserOpts);
console.log('CREATE EXTENSION parsed successfully');
} catch (error) {
console.log(
'CREATE EXTENSION parse error:',
(error as Error).message
);
}
console.log('\n=== Testing CREATE TABLE with custom type ===');
const createTableWithTypeSQL = `CREATE TABLE wizards (
id UUID PRIMARY KEY,
element spell_element DEFAULT 'fire'
);`;
try {
parser.astify(createTableWithTypeSQL, parserOpts);
console.log('CREATE TABLE with custom type parsed successfully');
} catch (error) {
console.log(
'CREATE TABLE with custom type parse error:',
(error as Error).message
);
}
console.log('\n=== Testing CREATE TABLE with standard types only ===');
const createTableStandardSQL = `CREATE TABLE wizards (
id UUID PRIMARY KEY,
element VARCHAR(20) DEFAULT 'fire'
);`;
try {
parser.astify(createTableStandardSQL, parserOpts);
console.log('CREATE TABLE with standard types parsed successfully');
} catch (error) {
console.log(
'CREATE TABLE with standard types parse error:',
(error as Error).message
);
}
});
});

View File

@@ -0,0 +1,178 @@
import { describe, it, expect } from 'vitest';
import { fromSQLite } from '../sqlite';
describe('SQLite Import Tests', () => {
it('should parse SQLite script with sqlite_sequence table and all relationships', async () => {
const sql = `
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
age INTEGER
);
CREATE TABLE sqlite_sequence(name,seq);
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
price REAL
);
CREATE TABLE user_products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
purchased_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (product_id) REFERENCES products(id)
);
`;
const result = await fromSQLite(sql);
// ============= CHECK TOTAL COUNTS =============
// Should have exactly 4 tables
expect(result.tables).toHaveLength(4);
// Should have exactly 2 foreign key relationships
expect(result.relationships).toHaveLength(2);
// ============= CHECK USERS TABLE =============
const usersTable = result.tables.find((t) => t.name === 'users');
expect(usersTable).toBeDefined();
expect(usersTable?.columns).toHaveLength(3); // id, name, age
// Check each column in users table
expect(usersTable?.columns[0]).toMatchObject({
name: 'id',
type: 'INTEGER',
primaryKey: true,
increment: true,
nullable: false,
});
expect(usersTable?.columns[1]).toMatchObject({
name: 'name',
type: 'TEXT',
primaryKey: false,
nullable: true,
});
expect(usersTable?.columns[2]).toMatchObject({
name: 'age',
type: 'INTEGER',
primaryKey: false,
nullable: true,
});
// ============= CHECK SQLITE_SEQUENCE TABLE =============
const sqliteSequenceTable = result.tables.find(
(t) => t.name === 'sqlite_sequence'
);
expect(sqliteSequenceTable).toBeDefined();
expect(sqliteSequenceTable?.columns).toHaveLength(2); // name, seq
// Check columns in sqlite_sequence table
expect(sqliteSequenceTable?.columns[0]).toMatchObject({
name: 'name',
type: 'TEXT', // Should default to TEXT when no type specified
primaryKey: false,
nullable: true,
});
expect(sqliteSequenceTable?.columns[1]).toMatchObject({
name: 'seq',
type: 'TEXT', // Should default to TEXT when no type specified
primaryKey: false,
nullable: true,
});
// ============= CHECK PRODUCTS TABLE =============
const productsTable = result.tables.find((t) => t.name === 'products');
expect(productsTable).toBeDefined();
expect(productsTable?.columns).toHaveLength(3); // id, name, price
// Check each column in products table
expect(productsTable?.columns[0]).toMatchObject({
name: 'id',
type: 'INTEGER',
primaryKey: true,
increment: true,
nullable: false,
});
expect(productsTable?.columns[1]).toMatchObject({
name: 'name',
type: 'TEXT',
primaryKey: false,
nullable: true,
});
expect(productsTable?.columns[2]).toMatchObject({
name: 'price',
type: 'REAL',
primaryKey: false,
nullable: true,
});
// ============= CHECK USER_PRODUCTS TABLE =============
const userProductsTable = result.tables.find(
(t) => t.name === 'user_products'
);
expect(userProductsTable).toBeDefined();
expect(userProductsTable?.columns).toHaveLength(4); // id, user_id, product_id, purchased_at
// Check each column in user_products table
expect(userProductsTable?.columns[0]).toMatchObject({
name: 'id',
type: 'INTEGER',
primaryKey: true,
increment: true,
nullable: false,
});
expect(userProductsTable?.columns[1]).toMatchObject({
name: 'user_id',
type: 'INTEGER',
primaryKey: false,
nullable: false, // NOT NULL constraint
});
expect(userProductsTable?.columns[2]).toMatchObject({
name: 'product_id',
type: 'INTEGER',
primaryKey: false,
nullable: false, // NOT NULL constraint
});
expect(userProductsTable?.columns[3]).toMatchObject({
name: 'purchased_at',
type: 'TIMESTAMP', // DATETIME should map to TIMESTAMP
primaryKey: false,
nullable: true,
default: 'CURRENT_TIMESTAMP',
});
// ============= CHECK FOREIGN KEY RELATIONSHIPS =============
// FK 1: user_products.user_id -> users.id
const userIdFK = result.relationships.find(
(r) =>
r.sourceTable === 'user_products' &&
r.sourceColumn === 'user_id' &&
r.targetTable === 'users' &&
r.targetColumn === 'id'
);
expect(userIdFK).toBeDefined();
expect(userIdFK).toMatchObject({
sourceTable: 'user_products',
sourceColumn: 'user_id',
targetTable: 'users',
targetColumn: 'id',
});
// FK 2: user_products.product_id -> products.id
const productIdFK = result.relationships.find(
(r) =>
r.sourceTable === 'user_products' &&
r.sourceColumn === 'product_id' &&
r.targetTable === 'products' &&
r.targetColumn === 'id'
);
expect(productIdFK).toBeDefined();
expect(productIdFK).toMatchObject({
sourceTable: 'user_products',
sourceColumn: 'product_id',
targetTable: 'products',
targetColumn: 'id',
});
});
});

View File

@@ -32,11 +32,11 @@ export async function fromSQLite(sqlContent: string): Promise<SQLParserResult> {
const tableMap: Record<string, string> = {}; // Maps table name to its ID
try {
// SPECIAL HANDLING: Direct line-by-line parser for SQLite DDL
// This ensures we preserve the exact data types from the original DDL
// SPECIAL HANDLING: Direct regex-based parser for SQLite DDL
// This ensures we handle all SQLite-specific syntax including tables without types
const directlyParsedTables = parseCreateTableStatements(sqlContent);
// Check if we successfully parsed tables directly
// Always try direct parsing first as it's more reliable for SQLite
if (directlyParsedTables.length > 0) {
// Map the direct parsing results to the expected SQLParserResult format
directlyParsedTables.forEach((table) => {
@@ -56,8 +56,19 @@ export async function fromSQLite(sqlContent: string): Promise<SQLParserResult> {
// Process foreign keys using the regex approach
findForeignKeysUsingRegex(sqlContent, tableMap, relationships);
// Return the result
return { tables, relationships };
// Create placeholder tables for any missing referenced tables
addPlaceholderTablesForFKReferences(
tables,
relationships,
tableMap
);
// Filter out any invalid relationships
const validRelationships = relationships.filter((rel) => {
return isValidForeignKeyRelationship(rel, tables);
});
return { tables, relationships: validRelationships };
}
// Preprocess SQL to handle SQLite quoted identifiers
@@ -130,101 +141,182 @@ function parseCreateTableStatements(sqlContent: string): {
columns: SQLColumn[];
}[] = [];
// Split SQL content into lines
const lines = sqlContent.split('\n');
let currentTable: { name: string; columns: SQLColumn[] } | null = null;
let inCreateTable = false;
// Process each line
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Skip empty lines and comments
if (!line || line.startsWith('--')) {
continue;
}
// Check for CREATE TABLE statement
if (line.toUpperCase().startsWith('CREATE TABLE')) {
// Extract table name
const tableNameMatch =
/CREATE\s+TABLE\s+(?:if\s+not\s+exists\s+)?["'`]?(\w+)["'`]?/i.exec(
line
);
if (tableNameMatch && tableNameMatch[1]) {
inCreateTable = true;
currentTable = {
name: tableNameMatch[1],
columns: [],
};
// Remove comments before processing
const cleanedSQL = sqlContent
.split('\n')
.map((line) => {
const commentIndex = line.indexOf('--');
if (commentIndex >= 0) {
return line.substring(0, commentIndex);
}
}
// Check for end of CREATE TABLE statement
else if (inCreateTable && line.includes(');')) {
if (currentTable) {
tables.push(currentTable);
return line;
})
.join('\n');
// Match all CREATE TABLE statements including those without column definitions
const createTableRegex =
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?\s*\(([^;]+?)\)\s*;/gis;
let match;
while ((match = createTableRegex.exec(cleanedSQL)) !== null) {
const tableName = match[1];
const tableBody = match[2].trim();
const table: { name: string; columns: SQLColumn[] } = {
name: tableName,
columns: [],
};
// Special case: sqlite_sequence or tables with columns but no types
if (tableName === 'sqlite_sequence' || !tableBody.includes(' ')) {
// Parse simple column list without types (e.g., "name,seq")
const simpleColumns = tableBody.split(',').map((col) => col.trim());
for (const colName of simpleColumns) {
if (
colName &&
!colName.toUpperCase().startsWith('FOREIGN KEY') &&
!colName.toUpperCase().startsWith('PRIMARY KEY') &&
!colName.toUpperCase().startsWith('UNIQUE') &&
!colName.toUpperCase().startsWith('CHECK') &&
!colName.toUpperCase().startsWith('CONSTRAINT')
) {
table.columns.push({
name: colName.replace(/["'`]/g, ''),
type: 'TEXT', // Default to TEXT for untyped columns
nullable: true,
primaryKey: false,
unique: false,
default: '',
increment: false,
});
}
}
inCreateTable = false;
currentTable = null;
}
// Process column definitions inside CREATE TABLE
else if (inCreateTable && currentTable && line.includes('"')) {
// Column line pattern optimized for user's DDL format
const columnPattern = /\s*["'`](\w+)["'`]\s+([A-Za-z0-9_]+)(.+)?/i;
const match = columnPattern.exec(line);
} else {
// Parse normal table with typed columns
// Split by commas not inside parentheses
const columnDefs = [];
let current = '';
let parenDepth = 0;
if (match) {
const columnName = match[1];
const rawType = match[2].toUpperCase();
const restOfLine = match[3] || '';
for (let i = 0; i < tableBody.length; i++) {
const char = tableBody[i];
if (char === '(') parenDepth++;
else if (char === ')') parenDepth--;
else if (char === ',' && parenDepth === 0) {
columnDefs.push(current.trim());
current = '';
continue;
}
current += char;
}
if (current.trim()) {
columnDefs.push(current.trim());
}
// Determine column properties
const isPrimaryKey = restOfLine
.toUpperCase()
.includes('PRIMARY KEY');
const isNotNull = restOfLine.toUpperCase().includes('NOT NULL');
const isUnique = restOfLine.toUpperCase().includes('UNIQUE');
for (const columnDef of columnDefs) {
const line = columnDef.trim();
// Extract default value
let defaultValue = '';
const defaultMatch = /DEFAULT\s+([^,\s)]+)/i.exec(restOfLine);
if (defaultMatch) {
defaultValue = defaultMatch[1];
// Skip constraints
if (
line.toUpperCase().startsWith('FOREIGN KEY') ||
line.toUpperCase().startsWith('PRIMARY KEY') ||
line.toUpperCase().startsWith('UNIQUE') ||
line.toUpperCase().startsWith('CHECK') ||
line.toUpperCase().startsWith('CONSTRAINT')
) {
continue;
}
// Map to appropriate SQLite storage class
let columnType = rawType;
if (rawType === 'INTEGER' || rawType === 'INT') {
columnType = 'INTEGER';
} else if (
['REAL', 'FLOAT', 'DOUBLE', 'NUMERIC', 'DECIMAL'].includes(
rawType
)
) {
columnType = 'REAL';
} else if (rawType === 'BLOB' || rawType === 'BINARY') {
columnType = 'BLOB';
} else if (
['TIMESTAMP', 'DATETIME', 'DATE'].includes(rawType)
) {
columnType = 'TIMESTAMP';
} else {
columnType = 'TEXT';
}
// Parse column: handle both quoted and unquoted identifiers
// Pattern: [quotes]columnName[quotes] dataType [constraints]
const columnPattern = /^["'`]?([\w]+)["'`]?\s+(\w+)(.*)$/i;
const columnMatch = columnPattern.exec(line);
// Add column to the table
currentTable.columns.push({
name: columnName,
type: columnType,
nullable: !isNotNull,
primaryKey: isPrimaryKey,
unique: isUnique || isPrimaryKey,
default: defaultValue,
increment: isPrimaryKey && columnType === 'INTEGER',
});
if (columnMatch) {
const columnName = columnMatch[1];
const rawType = columnMatch[2].toUpperCase();
const restOfLine = columnMatch[3] || '';
const upperRest = restOfLine.toUpperCase();
// Determine column properties
const isPrimaryKey = upperRest.includes('PRIMARY KEY');
const isAutoIncrement = upperRest.includes('AUTOINCREMENT');
const isNotNull =
upperRest.includes('NOT NULL') || isPrimaryKey;
const isUnique =
upperRest.includes('UNIQUE') || isPrimaryKey;
// Extract default value
let defaultValue = '';
const defaultMatch = /DEFAULT\s+([^,)]+)/i.exec(restOfLine);
if (defaultMatch) {
defaultValue = defaultMatch[1].trim();
// Remove quotes if present
if (
(defaultValue.startsWith("'") &&
defaultValue.endsWith("'")) ||
(defaultValue.startsWith('"') &&
defaultValue.endsWith('"'))
) {
defaultValue = defaultValue.slice(1, -1);
}
}
// Map to appropriate SQLite storage class
let columnType = rawType;
if (rawType === 'INTEGER' || rawType === 'INT') {
columnType = 'INTEGER';
} else if (
[
'REAL',
'FLOAT',
'DOUBLE',
'NUMERIC',
'DECIMAL',
].includes(rawType)
) {
columnType = 'REAL';
} else if (rawType === 'BLOB' || rawType === 'BINARY') {
columnType = 'BLOB';
} else if (
['TIMESTAMP', 'DATETIME', 'DATE', 'TIME'].includes(
rawType
)
) {
columnType = 'TIMESTAMP';
} else if (
['TEXT', 'VARCHAR', 'CHAR', 'CLOB', 'STRING'].includes(
rawType
) ||
rawType.startsWith('VARCHAR') ||
rawType.startsWith('CHAR')
) {
columnType = 'TEXT';
} else {
// Default to TEXT for unknown types
columnType = 'TEXT';
}
// Add column to the table
table.columns.push({
name: columnName,
type: columnType,
nullable: !isNotNull,
primaryKey: isPrimaryKey,
unique: isUnique,
default: defaultValue,
increment:
isPrimaryKey &&
isAutoIncrement &&
columnType === 'INTEGER',
});
}
}
}
if (table.columns.length > 0 || tableName === 'sqlite_sequence') {
tables.push(table);
}
}
return tables;

View File

@@ -1,105 +0,0 @@
/**
* Shared utilities for detecting SQL dialect-specific syntax
* Used across all validators to identify incompatible SQL dialects
*/
import type { ValidationError } from './postgresql-validator';
interface DialectDetectionResult {
detected: boolean;
dialect: string;
lines: number[];
features: string[];
}
/**
* Detect Oracle-specific SQL syntax in the given SQL content
*/
export function detectOracleSQL(lines: string[]): DialectDetectionResult {
const oracleTypeLines: number[] = [];
const detectedFeatures = new Set<string>();
lines.forEach((line, index) => {
const upperLine = line.trim().toUpperCase();
// Check for Oracle-specific data types
if (upperLine.includes('VARCHAR2')) {
detectedFeatures.add('VARCHAR2');
oracleTypeLines.push(index + 1);
}
if (
upperLine.match(/\bNUMBER\s*\(/i) ||
upperLine.match(/\bNUMBER\b(?!\s*\()/i)
) {
detectedFeatures.add('NUMBER');
oracleTypeLines.push(index + 1);
}
// Could add more Oracle-specific features in the future:
// - CLOB, BLOB data types
// - ROWNUM pseudo-column
// - CONNECT BY for hierarchical queries
// - MINUS set operator (vs EXCEPT in other DBs)
});
return {
detected: oracleTypeLines.length > 0,
dialect: 'Oracle',
lines: oracleTypeLines,
features: Array.from(detectedFeatures),
};
}
/**
* Create an Oracle SQL error for the target database type
*/
export function createOracleError(
detection: DialectDetectionResult,
targetDatabase: 'MySQL' | 'PostgreSQL' | 'SQL Server' | 'SQLite'
): ValidationError {
const lineList = detection.lines.slice(0, 5).join(', ');
const moreLines =
detection.lines.length > 5
? ` and ${detection.lines.length - 5} more locations`
: '';
const featuresText = detection.features.join(', ');
// Database-specific conversion suggestions
const conversionMap = {
MySQL: 'VARCHAR2 → VARCHAR, NUMBER → INT/DECIMAL/NUMERIC',
PostgreSQL: 'VARCHAR2 → VARCHAR, NUMBER → NUMERIC/INTEGER',
'SQL Server': 'VARCHAR2 → VARCHAR, NUMBER → INT/DECIMAL/NUMERIC',
SQLite: 'VARCHAR2 → TEXT, NUMBER → INTEGER/REAL',
};
return {
line: detection.lines[0],
message: `Oracle SQL syntax detected (${featuresText} types found on lines: ${lineList}${moreLines})`,
type: 'syntax',
suggestion: `This appears to be Oracle SQL. Please convert to ${targetDatabase} syntax: ${conversionMap[targetDatabase]}`,
};
}
/**
* Detect any foreign SQL dialect in the given content
* Returns null if no foreign dialect is detected
*/
export function detectForeignDialect(
lines: string[],
targetDatabase: 'MySQL' | 'PostgreSQL' | 'SQL Server' | 'SQLite'
): ValidationError | null {
// Check for Oracle SQL
const oracleDetection = detectOracleSQL(lines);
if (oracleDetection.detected) {
return createOracleError(oracleDetection, targetDatabase);
}
// Future: Could add detection for other dialects
// - DB2 specific syntax
// - Teradata specific syntax
// - etc.
return null;
}

View File

@@ -8,7 +8,6 @@ import type {
ValidationError,
ValidationWarning,
} from './postgresql-validator';
import { detectForeignDialect } from './dialect-detection';
/**
* Validates MySQL SQL syntax
@@ -35,16 +34,13 @@ export function validateMySQLDialect(sql: string): ValidationResult {
};
}
// TODO: Implement MySQL-specific validation
// For now, just do basic checks
// Check for common MySQL syntax patterns
const lines = sql.split('\n');
let tableCount = 0;
// Check for foreign SQL dialects
const foreignDialectError = detectForeignDialect(lines, 'MySQL');
if (foreignDialectError) {
errors.push(foreignDialectError);
}
lines.forEach((line, index) => {
const trimmedLine = line.trim();

View File

@@ -3,8 +3,6 @@
* Provides user-friendly error messages for common SQL syntax issues
*/
import { detectForeignDialect } from './dialect-detection';
export interface ValidationResult {
isValid: boolean;
errors: ValidationError[];
@@ -214,13 +212,7 @@ export function validatePostgreSQLDialect(sql: string): ValidationResult {
});
}
// 9. Check for foreign SQL dialects
const foreignDialectError = detectForeignDialect(lines, 'PostgreSQL');
if (foreignDialectError) {
errors.push(foreignDialectError);
}
// 10. Count CREATE TABLE statements
// 9. Count CREATE TABLE statements
let tableCount = 0;
const createTableRegex =
/CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?(?:\s+ONLY)?\s+(?:"?[^"\s.]+?"?\.)?["'`]?[^"'`\s.(]+["'`]?/gi;

View File

@@ -8,7 +8,6 @@ import type {
ValidationError,
ValidationWarning,
} from './postgresql-validator';
import { detectForeignDialect } from './dialect-detection';
/**
* Validates SQLite SQL syntax
@@ -42,12 +41,6 @@ export function validateSQLiteDialect(sql: string): ValidationResult {
const lines = sql.split('\n');
let tableCount = 0;
// Check for foreign SQL dialects
const foreignDialectError = detectForeignDialect(lines, 'SQLite');
if (foreignDialectError) {
errors.push(foreignDialectError);
}
lines.forEach((line, index) => {
const trimmedLine = line.trim();

View File

@@ -8,7 +8,6 @@ import type {
ValidationError,
ValidationWarning,
} from './postgresql-validator';
import { detectForeignDialect } from './dialect-detection';
/**
* Validates SQL Server SQL syntax
@@ -35,16 +34,13 @@ export function validateSQLServerDialect(sql: string): ValidationResult {
};
}
// TODO: Implement SQL Server-specific validation
// For now, just do basic checks
// Check for common SQL Server syntax patterns
const lines = sql.split('\n');
let tableCount = 0;
// Check for foreign SQL dialects
const foreignDialectError = detectForeignDialect(lines, 'SQL Server');
if (foreignDialectError) {
errors.push(foreignDialectError);
}
lines.forEach((line, index) => {
const trimmedLine = line.trim();

View File

@@ -0,0 +1,7 @@
Table "public"."orders" {
"order_id" integer [pk, not null]
"customer_id" integer [not null]
"order_date" date [not null, default: `CURRENT_DATE`]
"total_amount" numeric [not null, default: 0]
"status" varchar(50) [not null, default: 'Pending']
}

View File

@@ -0,0 +1 @@
{"id":"6b81a1787207","name":"SQL Import (postgresql)","createdAt":"2025-09-15T08:46:26.747Z","updatedAt":"2025-09-17T11:32:13.876Z","databaseType":"postgresql","tables":[{"id":"5ytf0yj9etpmm7mhmhvpu8kfj","name":"orders","schema":"public","order":1,"fields":[{"id":"w7l77cy9hylvlitdovt4ktdmk","name":"order_id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":true,"unique":false,"default":"","createdAt":1757925986747,"increment":true},{"id":"vz7747t5fxrb62v1eepmahv9v","name":"customer_id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","createdAt":1757925986747,"increment":false},{"id":"geq9qy6sv4ozl2lg9fvcyzxpf","name":"order_date","type":{"name":"date","id":"date","usageLevel":1},"nullable":false,"primaryKey":false,"unique":false,"default":"CURRENT_DATE()","createdAt":1757925986747,"increment":false},{"id":"z928n7umvpec79t2eif7kmde9","name":"total_amount","type":{"name":"numeric","id":"numeric","fieldAttributes":{"precision":{"max":999,"min":1,"default":10},"scale":{"max":999,"min":0,"default":2}}},"nullable":false,"primaryKey":false,"unique":false,"default":"0","createdAt":1757925986747,"increment":false},{"id":"7bkrd0rp1s17bi1lnle6pesc7","name":"status","type":{"name":"varchar","id":"varchar","fieldAttributes":{"hasCharMaxLength":true},"usageLevel":1},"nullable":false,"primaryKey":false,"unique":false,"default":"'Pending'","createdAt":1757925986747,"increment":false,"characterMaximumLength":"50"}],"indexes":[],"x":113,"y":747,"color":"#8eb7ff","isView":false,"createdAt":1757925986747,"diagramId":"6b81a1787207","parentAreaId":null}],"relationships":[],"dependencies":[],"storageMode":"project","lastProjectSavedAt":"2025-09-17T11:32:13.876Z","areas":[],"creationMethod":"imported","customTypes":[]}

View File

@@ -0,0 +1,129 @@
Enum "cbhpm_entradas_tipo" {
"grupo"
"subgrupo"
"procedimento"
}
Enum "cid_entradas_tipo" {
"capitulo"
"agrupamento"
"categoria"
"subcategoria"
}
Enum "digital_signature_provider" {
"soluti"
"valid"
}
Enum "impresso_posicao" {
"start"
"center"
"end"
}
Enum "otp_provider" {
"clinic"
"soluti_bird_id"
}
Enum "tipo_cobranca" {
"valor"
"porte"
}
Enum "tipo_contato_movel" {
"celular"
"telefone_residencial"
"telefone_comercial"
}
Enum "tipo_contrato" {
"trial"
"common"
}
Enum "tipo_endereco" {
"residencial"
"comercial"
"cobranca"
}
Enum "tipo_espectro_autista" {
"leve"
"moderado"
"severo"
}
Enum "tipo_estado_civil" {
"nao_infomado"
"solteiro"
"casado"
"divorciado"
"viuvo"
}
Enum "tipo_etnia" {
"nao_infomado"
"branca"
"preta"
"parda"
"amarela"
"indigena"
}
Enum "tipo_excecao" {
"bloqueio"
"compromisso"
}
Enum "tipo_metodo_reajuste" {
"percentual"
"valor"
}
Enum "tipo_pessoa" {
"fisica"
"juridica"
}
Enum "tipo_procedimento" {
"consulta"
"exame_laboratorial"
"exame_imagem"
"procedimento_clinico"
"procedimento_cirurgico"
"terapia"
"outros"
}
Enum "tipo_relacionamento" {
"pai"
"mae"
"conjuge"
"filho_a"
"tutor_legal"
"contato_emergencia"
"outro"
}
Enum "tipo_sexo" {
"nao_infomado"
"masculino"
"feminino"
"intersexo"
}
Enum "tipo_status_agendamento" {
"em espera"
"faltou"
"ok"
}
Table "public"."organizacao_cfg_impressos" {
"id_organizacao" integer [pk, not null, ref: < "public"."organizacao"."id"]
}
Table "public"."organizacao" {
"id" integer [pk, not null]
}

File diff suppressed because one or more lines are too long

View File

@@ -4,64 +4,66 @@ import { generateDBMLFromDiagram } from '../dbml-export';
import * as fs from 'fs';
import * as path from 'path';
describe('DBML Export - Diagram Case 1 Tests', () => {
const testCase = (caseNumber: string) => {
// Read the JSON file
const jsonPath = path.join(__dirname, 'cases', `${caseNumber}.json`);
const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
// Parse the JSON and convert to diagram
const diagram = diagramFromJSONInput(jsonContent);
// Generate DBML from the diagram
const result = generateDBMLFromDiagram(diagram);
// Check for both regular and inline DBML files
const regularDbmlPath = path.join(__dirname, 'cases', `${caseNumber}.dbml`);
const inlineDbmlPath = path.join(
__dirname,
'cases',
`${caseNumber}.inline.dbml`
);
const hasRegularDbml = fs.existsSync(regularDbmlPath);
const hasInlineDbml = fs.existsSync(inlineDbmlPath);
// Test regular DBML if file exists
if (hasRegularDbml) {
const expectedRegularDBML = fs.readFileSync(regularDbmlPath, 'utf-8');
expect(result.standardDbml).toBe(expectedRegularDBML);
}
// Test inline DBML if file exists
if (hasInlineDbml) {
const expectedInlineDBML = fs.readFileSync(inlineDbmlPath, 'utf-8');
expect(result.inlineDbml).toBe(expectedInlineDBML);
}
// Ensure at least one DBML file exists
if (!hasRegularDbml && !hasInlineDbml) {
throw new Error(
`No DBML file found for test case ${caseNumber}. Expected either ${caseNumber}.dbml or ${caseNumber}.inline.dbml`
);
}
};
describe('DBML Export cases', () => {
it('should handle case 1 diagram', { timeout: 30000 }, async () => {
// Read the JSON file
const jsonPath = path.join(__dirname, 'cases', '1.json');
const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
// Parse the JSON and convert to diagram
const diagram = diagramFromJSONInput(jsonContent);
// Generate DBML from the diagram
const result = generateDBMLFromDiagram(diagram);
const generatedDBML = result.standardDbml;
// Read the expected DBML file
const dbmlPath = path.join(__dirname, 'cases', '1.dbml');
const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8');
// Compare the generated DBML with the expected DBML
expect(generatedDBML).toBe(expectedDBML);
testCase('1');
});
it('should handle case 2 diagram', { timeout: 30000 }, async () => {
// Read the JSON file
const jsonPath = path.join(__dirname, 'cases', '2.json');
const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
// Parse the JSON and convert to diagram
const diagram = diagramFromJSONInput(jsonContent);
// Generate DBML from the diagram
const result = generateDBMLFromDiagram(diagram);
const generatedDBML = result.standardDbml;
// Read the expected DBML file
const dbmlPath = path.join(__dirname, 'cases', '2.dbml');
const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8');
// Compare the generated DBML with the expected DBML
expect(generatedDBML).toBe(expectedDBML);
testCase('2');
});
it('should handle case 3 diagram', { timeout: 30000 }, async () => {
// Read the JSON file
const jsonPath = path.join(__dirname, 'cases', '3.json');
const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
testCase('3');
});
// Parse the JSON and convert to diagram
const diagram = diagramFromJSONInput(jsonContent);
it('should handle case 4 diagram', { timeout: 30000 }, async () => {
testCase('4');
});
// Generate DBML from the diagram
const result = generateDBMLFromDiagram(diagram);
const generatedDBML = result.standardDbml;
// Read the expected DBML file
const dbmlPath = path.join(__dirname, 'cases', '3.dbml');
const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8');
// Compare the generated DBML with the expected DBML
expect(generatedDBML).toBe(expectedDBML);
it('should handle case 5 diagram', { timeout: 30000 }, async () => {
testCase('5');
});
});

View File

@@ -3,7 +3,6 @@ import { exportBaseSQL } from '@/lib/data/sql-export/export-sql-script';
import type { Diagram } from '@/lib/domain/diagram';
import { DatabaseType } from '@/lib/domain/database-type';
import type { DBTable } from '@/lib/domain/db-table';
import { type DBField } from '@/lib/domain/db-field';
import type { DBCustomType } from '@/lib/domain/db-custom-type';
import { DBCustomTypeKind } from '@/lib/domain/db-custom-type';
@@ -502,38 +501,35 @@ const convertToInlineRefs = (dbml: string): string => {
return cleanedDbml;
};
// Function to check for DBML reserved keywords
const isDBMLKeyword = (name: string): boolean => {
const keywords = new Set([
'YES',
'NO',
'TRUE',
'FALSE',
'NULL', // DBML reserved keywords (boolean literals)
]);
return keywords.has(name.toUpperCase());
};
// Function to check for SQL keywords (add more if needed)
const isSQLKeyword = (name: string): boolean => {
const keywords = new Set(['CASE', 'ORDER', 'GROUP', 'FROM', 'TO', 'USER']); // Common SQL keywords
return keywords.has(name.toUpperCase());
};
// Function to remove duplicate relationships from the diagram
const deduplicateRelationships = (diagram: Diagram): Diagram => {
if (!diagram.relationships) return diagram;
const seenRelationships = new Set<string>();
const seenBidirectional = new Set<string>();
const uniqueRelationships = diagram.relationships.filter((rel) => {
// Create a unique key based on the relationship endpoints
const relationshipKey = `${rel.sourceTableId}-${rel.sourceFieldId}->${rel.targetTableId}-${rel.targetFieldId}`;
// Create a normalized key that's the same for both directions
const normalizedKey = [
`${rel.sourceTableId}-${rel.sourceFieldId}`,
`${rel.targetTableId}-${rel.targetFieldId}`,
]
.sort()
.join('<->');
if (seenRelationships.has(relationshipKey)) {
return false; // Skip duplicate
return false; // Skip exact duplicate
}
if (seenBidirectional.has(normalizedKey)) {
// This is a bidirectional relationship, skip the second one
return false;
}
seenRelationships.add(relationshipKey);
seenBidirectional.add(normalizedKey);
return true; // Keep unique relationship
});
@@ -543,48 +539,6 @@ const deduplicateRelationships = (diagram: Diagram): Diagram => {
};
};
// Function to append comment statements for renamed tables and fields
const appendRenameComments = (
baseScript: string,
sqlRenamedTables: Map<string, string>,
fieldRenames: Array<{
table: string;
originalName: string;
newName: string;
}>,
finalDiagramForExport: Diagram
): string => {
let script = baseScript;
// Append COMMENTS for tables renamed due to SQL keywords
sqlRenamedTables.forEach((originalName, newName) => {
const escapedOriginal = originalName.replace(/'/g, "\\'");
// Find the table to get its schema
const table = finalDiagramForExport.tables?.find(
(t) => t.name === newName
);
const tableIdentifier = table?.schema
? `"${table.schema}"."${newName}"`
: `"${newName}"`;
script += `\nCOMMENT ON TABLE ${tableIdentifier} IS 'Original name was "${escapedOriginal}" (renamed due to SQL keyword conflict).';`;
});
// Append COMMENTS for fields renamed due to SQL keyword conflicts
fieldRenames.forEach(({ table, originalName, newName }) => {
const escapedOriginal = originalName.replace(/'/g, "\\'");
// Find the table to get its schema
const tableObj = finalDiagramForExport.tables?.find(
(t) => t.name === table
);
const tableIdentifier = tableObj?.schema
? `"${tableObj.schema}"."${table}"`
: `"${table}"`;
script += `\nCOMMENT ON COLUMN ${tableIdentifier}."${newName}" IS 'Original name was "${escapedOriginal}" (renamed due to SQL keyword conflict).';`;
});
return script;
};
// Fix DBML formatting to ensure consistent display of char and varchar types
const normalizeCharTypeFormat = (dbml: string): string => {
// Replace "char (N)" with "char(N)" to match varchar's formatting
@@ -778,9 +732,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;
}
@@ -843,105 +805,33 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
// Sanitize field names ('from'/'to' in 'relation' table)
const cleanDiagram = fixProblematicFieldNames(filteredDiagram);
// --- Final sanitization and renaming pass ---
// Only rename keywords for PostgreSQL/SQLite
// For other databases, we'll wrap problematic names in quotes instead
const shouldRenameKeywords =
diagram.databaseType === DatabaseType.POSTGRESQL ||
diagram.databaseType === DatabaseType.SQLITE;
const sqlRenamedTables = new Map<string, string>();
const fieldRenames: Array<{
table: string;
originalName: string;
newName: string;
}> = [];
// Simplified processing - just handle duplicate field names
const processTable = (table: DBTable) => {
const originalName = table.name;
let safeTableName = originalName;
// If name contains spaces or special characters, wrap in quotes
if (/[^\w]/.test(originalName)) {
safeTableName = `"${originalName.replace(/"/g, '\\"')}"`;
}
// Rename table if it's a keyword (PostgreSQL/SQLite only)
if (
shouldRenameKeywords &&
(isDBMLKeyword(originalName) || isSQLKeyword(originalName))
) {
const newName = `${originalName}_table`;
sqlRenamedTables.set(newName, originalName);
safeTableName = /[^\w]/.test(newName)
? `"${newName.replace(/"/g, '\\"')}"`
: newName;
}
// For other databases, just quote DBML keywords
else if (!shouldRenameKeywords && isDBMLKeyword(originalName)) {
safeTableName = `"${originalName.replace(/"/g, '\\"')}"`;
}
const fieldNameCounts = new Map<string, number>();
const processedFields = table.fields.map((field) => {
let finalSafeName = field.name;
// If field name contains spaces or special characters, wrap in quotes
if (/[^\w]/.test(field.name)) {
finalSafeName = `"${field.name.replace(/"/g, '\\"')}"`;
}
// Handle duplicate field names
const count = fieldNameCounts.get(field.name) || 0;
if (count > 0) {
const newName = `${field.name}_${count + 1}`;
finalSafeName = /[^\w]/.test(newName)
? `"${newName.replace(/"/g, '\\"')}"`
: newName;
return {
...field,
name: newName,
};
}
fieldNameCounts.set(field.name, count + 1);
// Create sanitized field
const sanitizedField: DBField = {
...field,
name: finalSafeName,
};
// Rename field if it's a keyword (PostgreSQL/SQLite only)
if (
shouldRenameKeywords &&
(isDBMLKeyword(field.name) || isSQLKeyword(field.name))
) {
const newFieldName = `${field.name}_field`;
fieldRenames.push({
table: safeTableName,
originalName: field.name,
newName: newFieldName,
});
sanitizedField.name = /[^\w]/.test(newFieldName)
? `"${newFieldName.replace(/"/g, '\\"')}"`
: newFieldName;
}
// For other databases, just quote DBML keywords
else if (!shouldRenameKeywords && isDBMLKeyword(field.name)) {
sanitizedField.name = `"${field.name.replace(/"/g, '\\"')}"`;
}
return sanitizedField;
return field;
});
return {
...table,
name: safeTableName,
fields: processedFields,
indexes: (table.indexes || [])
.filter((index) => !index.isPrimaryKey) // Filter out PK indexes as they're handled separately
.map((index) => ({
...index,
name: index.name
? /[^\w]/.test(index.name)
? `"${index.name.replace(/"/g, '\\"')}"`
: index.name
: `idx_${Math.random().toString(36).substring(2, 8)}`,
name:
index.name ||
`idx_${Math.random().toString(36).substring(2, 8)}`,
})),
};
};
@@ -979,19 +869,6 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
baseScript = sanitizeSQLforDBML(baseScript);
// Append comments for renamed tables and fields (PostgreSQL/SQLite only)
if (
shouldRenameKeywords &&
(sqlRenamedTables.size > 0 || fieldRenames.length > 0)
) {
baseScript = appendRenameComments(
baseScript,
sqlRenamedTables,
fieldRenames,
finalDiagramForExport
);
}
standard = fixArrayTypes(
normalizeCharTypeFormat(
fixMultilineTableNames(
@@ -1054,5 +931,13 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
}
}
return { standardDbml: standard, inlineDbml: inline, error: errorMsg };
// Extract relationships DBML from standard output
const relationshipsDbml = extractRelationshipsDbml(standard);
return {
standardDbml: standard,
inlineDbml: inline,
relationshipsDbml,
error: errorMsg,
};
}

View File

@@ -0,0 +1,3 @@
Table "public"."table_3"{
"id" bigint [pk]
}

View File

@@ -0,0 +1 @@
{"id":"mqqwkkodxt6p","name":"Diagram 3","createdAt":"2025-09-16T15:33:25.300Z","updatedAt":"2025-09-16T15:33:31.563Z","databaseType":"postgresql","tables":[{"id":"loyxg6mafzos5u971uirjs3zh","name":"table_3","schema":"","order":0,"fields":[{"id":"29e2p9bom0uxo1n0a9ze5auuy","name":"id","type":{"name":"bigint","id":"bigint","usageLevel":2},"nullable":true,"primaryKey":true,"unique":true,"createdAt":1758036805300}],"indexes":[{"id":"5gf0aeptch1uk1bxv0x89wxxe","name":"pk_table_3_id","fieldIds":["29e2p9bom0uxo1n0a9ze5auuy"],"unique":true,"isPrimaryKey":true,"createdAt":1758036811564}],"x":0,"y":0,"color":"#8eb7ff","isView":false,"createdAt":1758036805300,"diagramId":"mqqwkkodxt6p"}],"relationships":[],"dependencies":[],"areas":[],"customTypes":[]}

View File

@@ -0,0 +1,7 @@
Table "table_3" {
"id" bigint [pk]
}
Table "table_2" {
"id" bigint [pk, not null, ref: < "table_3"."id"]
}

View File

@@ -0,0 +1 @@
{"id":"mqqwkkod6r09","name":"Diagram 10","createdAt":"2025-09-16T15:47:40.655Z","updatedAt":"2025-09-16T15:47:50.179Z","databaseType":"postgresql","tables":[{"id":"6xbco4ihmuiyv2heuw9fggbgx","name":"table_3","schema":"","order":0,"fields":[{"id":"rxftaey7uxvq5qg6ix1hbak1c","name":"id","type":{"name":"bigint","id":"bigint","usageLevel":2},"nullable":true,"primaryKey":true,"unique":true,"createdAt":1758037660654}],"indexes":[{"id":"vsyjjaq2l58urkh9qm2g9hqhd","name":"pk_table_3_id","fieldIds":["rxftaey7uxvq5qg6ix1hbak1c"],"unique":true,"isPrimaryKey":true,"createdAt":1758037660654}],"x":0,"y":0,"color":"#8eb7ff","isView":false,"createdAt":1758037660654,"diagramId":"mqqwkkod6r09"},{"id":"klu6k5ntddcxfdsu0fsfcwbiw","name":"table_2","schema":"","order":1,"fields":[{"id":"qq2415tivmtvun8vd727d9mr2","name":"id","type":{"name":"bigint","id":"bigint","usageLevel":2},"nullable":false,"primaryKey":true,"unique":true,"createdAt":1758037660655}],"indexes":[{"id":"cvv7sgmq07i9y54lz9a97nah5","name":"pk_table_2_id","fieldIds":["qq2415tivmtvun8vd727d9mr2"],"unique":true,"isPrimaryKey":true,"createdAt":1758037660655}],"x":300,"y":0,"color":"#8eb7ff","isView":false,"createdAt":1758037660655,"diagramId":"mqqwkkod6r09"}],"relationships":[{"id":"yw2pbcumsabuncc6rjnp3n87t","name":"table_3_id_table_2_id","sourceSchema":"","targetSchema":"","sourceTableId":"6xbco4ihmuiyv2heuw9fggbgx","targetTableId":"klu6k5ntddcxfdsu0fsfcwbiw","sourceFieldId":"rxftaey7uxvq5qg6ix1hbak1c","targetFieldId":"qq2415tivmtvun8vd727d9mr2","sourceCardinality":"one","targetCardinality":"one","createdAt":1758037660655,"diagramId":"mqqwkkod6r09"}],"dependencies":[],"areas":[],"customTypes":[]}

View File

@@ -0,0 +1,345 @@
import { describe, it, expect } from 'vitest';
import { importDBMLToDiagram } from '../dbml-import';
import * as fs from 'fs';
import * as path from 'path';
import { DatabaseType } from '@/lib/domain/database-type';
import type { DBTable } from '@/lib/domain/db-table';
import type { DBField } from '@/lib/domain/db-field';
import type { DBRelationship } from '@/lib/domain/db-relationship';
import { defaultSchemas } from '@/lib/data/default-schemas';
// Type for field map entries
interface FieldMapEntry {
tableName: string;
fieldName: string;
}
// Helper function to compare field properties (excluding IDs and timestamps)
function expectFieldsMatch(
actualFields: DBField[],
expectedFields: DBField[]
): void {
expect(actualFields).toHaveLength(expectedFields.length);
for (let i = 0; i < actualFields.length; i++) {
const actual = actualFields[i];
const expected = expectedFields[i];
// Compare field properties (excluding ID and createdAt)
expect(actual.name).toBe(expected.name);
// Handle type comparison (could be string or object with name property)
if (typeof expected.type === 'object' && expected.type?.name) {
expect(actual.type?.name).toBe(expected.type.name);
} else if (typeof expected.type === 'string') {
expect(actual.type?.name).toBe(expected.type);
}
// Boolean flags with defaults
expect(actual.primaryKey).toBe(expected.primaryKey || false);
expect(actual.unique).toBe(expected.unique || false);
expect(actual.nullable).toBe(expected.nullable ?? true);
// Optional boolean flag
if (expected.increment !== undefined) {
expect(actual.increment).toBe(expected.increment);
}
// Optional string/number properties
if (expected.characterMaximumLength !== undefined) {
expect(actual.characterMaximumLength).toBe(
expected.characterMaximumLength
);
}
if (expected.precision !== undefined) {
expect(actual.precision).toBe(expected.precision);
}
if (expected.scale !== undefined) {
expect(actual.scale).toBe(expected.scale);
}
if (expected.default !== undefined) {
expect(actual.default).toBe(expected.default);
}
if (expected.collation !== undefined) {
expect(actual.collation).toBe(expected.collation);
}
if (expected.comments !== undefined) {
expect(actual.comments).toBe(expected.comments);
}
}
}
// Helper function to compare table properties (excluding IDs)
function expectTablesMatch(
actualTables: DBTable[],
expectedTables: DBTable[],
databaseType: DatabaseType
): void {
expect(actualTables).toHaveLength(expectedTables.length);
// Sort tables by name for consistent comparison
const sortedActual = [...actualTables].sort((a, b) =>
a.name.localeCompare(b.name)
);
const sortedExpected = [...expectedTables].sort((a, b) =>
a.name.localeCompare(b.name)
);
for (let i = 0; i < sortedActual.length; i++) {
const actual = sortedActual[i];
const expected = sortedExpected[i];
// Compare table properties (excluding ID and position)
expect(actual.name).toBe(expected.name);
// Schema comparison - handle differences in how schemas are represented
if (expected.schema) {
const defaultSchema = defaultSchemas[databaseType];
if (defaultSchema && expected.schema === defaultSchema) {
// DBML parser might not include default schema or might handle it differently
expect(
actual.schema === expected.schema ||
actual.schema === '' ||
actual.schema === undefined
).toBeTruthy();
} else {
expect(actual.schema).toBe(expected.schema);
}
}
// Compare fields
expectFieldsMatch(actual.fields, expected.fields);
// Check indexes exist for tables with primary keys
const hasPrimaryKeyField = actual.fields.some((f) => f.primaryKey);
if (hasPrimaryKeyField) {
expect(actual.indexes).toBeDefined();
expect(actual.indexes.length).toBeGreaterThan(0);
const pkIndex = actual.indexes.find((idx) => idx.isPrimaryKey);
expect(pkIndex).toBeDefined();
expect(pkIndex?.unique).toBe(true);
}
// Check comments if present
if (expected.comments !== undefined) {
expect(actual.comments).toBe(expected.comments);
}
}
}
// Helper function to compare relationships (excluding IDs)
function expectRelationshipsMatch(
actualRelationships: DBRelationship[],
expectedRelationships: DBRelationship[],
actualTables: DBTable[],
expectedTables: DBTable[]
): void {
expect(actualRelationships).toHaveLength(expectedRelationships.length);
// Create lookup maps for table and field names by ID
const expectedTableMap = new Map(expectedTables.map((t) => [t.id, t.name]));
const actualTableMap = new Map(actualTables.map((t) => [t.id, t.name]));
const expectedFieldMap = new Map<string, FieldMapEntry>();
const actualFieldMap = new Map<string, FieldMapEntry>();
expectedTables.forEach((table) => {
table.fields.forEach((field) => {
expectedFieldMap.set(field.id, {
tableName: table.name,
fieldName: field.name,
});
});
});
actualTables.forEach((table) => {
table.fields.forEach((field) => {
actualFieldMap.set(field.id, {
tableName: table.name,
fieldName: field.name,
});
});
});
// Sort relationships for consistent comparison
const sortRelationships = (
rels: DBRelationship[],
tableMap: Map<string, string>,
fieldMap: Map<string, FieldMapEntry>
) => {
return [...rels].sort((a, b) => {
const aSourceTable = tableMap.get(a.sourceTableId) || '';
const bSourceTable = tableMap.get(b.sourceTableId) || '';
const aTargetTable = tableMap.get(a.targetTableId) || '';
const bTargetTable = tableMap.get(b.targetTableId) || '';
const tableCompare =
aSourceTable.localeCompare(bSourceTable) ||
aTargetTable.localeCompare(bTargetTable);
if (tableCompare !== 0) return tableCompare;
const aSourceField = fieldMap.get(a.sourceFieldId)?.fieldName || '';
const bSourceField = fieldMap.get(b.sourceFieldId)?.fieldName || '';
const aTargetField = fieldMap.get(a.targetFieldId)?.fieldName || '';
const bTargetField = fieldMap.get(b.targetFieldId)?.fieldName || '';
return (
aSourceField.localeCompare(bSourceField) ||
aTargetField.localeCompare(bTargetField)
);
});
};
const sortedActual = sortRelationships(
actualRelationships,
actualTableMap,
actualFieldMap
);
const sortedExpected = sortRelationships(
expectedRelationships,
expectedTableMap,
expectedFieldMap
);
for (let i = 0; i < sortedActual.length; i++) {
const actual = sortedActual[i];
const expected = sortedExpected[i];
// Get table and field names for comparison
const actualSourceTable = actualTableMap.get(actual.sourceTableId);
const actualTargetTable = actualTableMap.get(actual.targetTableId);
const expectedSourceTable = expectedTableMap.get(
expected.sourceTableId
);
const expectedTargetTable = expectedTableMap.get(
expected.targetTableId
);
const actualSourceField = actualFieldMap.get(actual.sourceFieldId);
const actualTargetField = actualFieldMap.get(actual.targetFieldId);
const expectedSourceField = expectedFieldMap.get(
expected.sourceFieldId
);
const expectedTargetField = expectedFieldMap.get(
expected.targetFieldId
);
// Compare relationship by table and field names
expect(actualSourceTable).toBe(expectedSourceTable);
expect(actualTargetTable).toBe(expectedTargetTable);
expect(actualSourceField?.fieldName).toBe(
expectedSourceField?.fieldName
);
expect(actualTargetField?.fieldName).toBe(
expectedTargetField?.fieldName
);
// Compare cardinality
expect(actual.sourceCardinality).toBe(expected.sourceCardinality);
expect(actual.targetCardinality).toBe(expected.targetCardinality);
// Compare relationship name if present
if (expected.name !== undefined) {
expect(actual.name).toBe(expected.name);
}
}
}
// Main test helper function
async function testDBMLImportCase(caseNumber: string): Promise<void> {
// Read the DBML file
const dbmlPath = path.join(__dirname, 'cases', `${caseNumber}.dbml`);
const dbmlContent = fs.readFileSync(dbmlPath, 'utf-8');
// Read the expected JSON file
const jsonPath = path.join(__dirname, 'cases', `${caseNumber}.json`);
const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
const expectedData = JSON.parse(jsonContent);
// Import DBML to diagram
const result = await importDBMLToDiagram(dbmlContent, {
databaseType: expectedData.databaseType || DatabaseType.POSTGRESQL,
});
// Check basic diagram properties
expect(result.name).toBe('DBML Import'); // Name is always 'DBML Import'
expect(result.databaseType).toBe(expectedData.databaseType);
// Check tables and fields
expectTablesMatch(
result.tables || [],
expectedData.tables || [],
expectedData.databaseType || DatabaseType.POSTGRESQL
);
// Check relationships
expectRelationshipsMatch(
result.relationships || [],
expectedData.relationships || [],
result.tables || [],
expectedData.tables || []
);
}
describe('DBML Import cases', () => {
it('should handle case 1 - simple table with pk and unique', async () => {
await testDBMLImportCase('1');
});
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()');
});
});

View File

@@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest';
import { DatabaseType } from '@/lib/domain/database-type';
import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import';
// This test verifies the DBML integration without UI components
describe('DBML Integration Tests', () => {
it('should handle DBML import in create diagram flow', async () => {
const dbmlContent = `
Table users {
id uuid [pk, not null]
email varchar [unique, not null]
created_at timestamp
}
Table posts {
id uuid [pk]
title varchar
content text
user_id uuid [ref: > users.id]
created_at timestamp
}
Table comments {
id uuid [pk]
content text
post_id uuid [ref: > posts.id]
user_id uuid [ref: > users.id]
}
// This will be ignored
TableGroup "Content" {
posts
comments
}
// This will be ignored too
Note test_note {
'This is a test note'
}`;
const diagram = await importDBMLToDiagram(dbmlContent);
// Verify basic structure
expect(diagram).toBeDefined();
expect(diagram.tables).toHaveLength(3);
expect(diagram.relationships).toHaveLength(3);
// Verify tables
const tableNames = diagram.tables?.map((t) => t.name).sort();
expect(tableNames).toEqual(['comments', 'posts', 'users']);
// Verify users table
const usersTable = diagram.tables?.find((t) => t.name === 'users');
expect(usersTable).toBeDefined();
expect(usersTable?.fields).toHaveLength(3);
const emailField = usersTable?.fields.find((f) => f.name === 'email');
expect(emailField?.unique).toBe(true);
expect(emailField?.nullable).toBe(false);
// Verify relationships
// There should be 3 relationships total
expect(diagram.relationships).toHaveLength(3);
// Find the relationship from users to posts (DBML ref is: posts.user_id > users.id)
// This creates a relationship FROM users TO posts (one user has many posts)
const postsTable = diagram.tables?.find((t) => t.name === 'posts');
const usersTableId = usersTable?.id;
const userPostRelation = diagram.relationships?.find(
(r) =>
r.sourceTableId === usersTableId &&
r.targetTableId === postsTable?.id
);
expect(userPostRelation).toBeDefined();
expect(userPostRelation?.sourceCardinality).toBe('one');
expect(userPostRelation?.targetCardinality).toBe('many');
});
it('should handle DBML with special features', async () => {
const dbmlContent = `
// Enum will be converted to varchar
Table users {
id int [pk]
status enum
tags text[] // Array will be converted to text
favorite_product_id int
}
Table products [headercolor: #FF0000] {
id int [pk]
name varchar
price decimal(10,2)
}
Ref: products.id < users.favorite_product_id`;
const diagram = await importDBMLToDiagram(dbmlContent);
expect(diagram.tables).toHaveLength(2);
// Check enum conversion
const usersTable = diagram.tables?.find((t) => t.name === 'users');
const statusField = usersTable?.fields.find((f) => f.name === 'status');
expect(statusField?.type.id).toBe('varchar');
// Check array type conversion
const tagsField = usersTable?.fields.find((f) => f.name === 'tags');
expect(tagsField?.type.id).toBe('text');
// Check that header color was removed
const productsTable = diagram.tables?.find(
(t) => t.name === 'products'
);
expect(productsTable).toBeDefined();
expect(productsTable?.name).toBe('products');
});
it('should handle empty or invalid DBML gracefully', async () => {
// Empty DBML
const emptyDiagram = await importDBMLToDiagram('');
expect(emptyDiagram.tables).toHaveLength(0);
expect(emptyDiagram.relationships).toHaveLength(0);
// Only comments
const commentDiagram = await importDBMLToDiagram('// Just a comment');
expect(commentDiagram.tables).toHaveLength(0);
expect(commentDiagram.relationships).toHaveLength(0);
});
it('should preserve diagram metadata when importing DBML', async () => {
const dbmlContent = `Table test {
id int [pk]
}`;
const diagram = await importDBMLToDiagram(dbmlContent);
// Default values
expect(diagram.name).toBe('DBML Import');
expect(diagram.databaseType).toBe(DatabaseType.GENERIC);
// These can be overridden by the dialog
diagram.name = 'My Custom Diagram';
diagram.databaseType = DatabaseType.POSTGRESQL;
expect(diagram.name).toBe('My Custom Diagram');
expect(diagram.databaseType).toBe(DatabaseType.POSTGRESQL);
});
});

View File

@@ -180,7 +180,7 @@ describe('DBML Schema Handling - Fantasy Realm Database', () => {
expect(artifactsTable?.schema).toBe(''); // No schema = empty string
});
it('should rename reserved keywords for PostgreSQL', async () => {
it('should handle reserved keywords for PostgreSQL', async () => {
const dbmlContent = `
Table "magic_items" {
"id" bigint [pk]
@@ -197,10 +197,9 @@ describe('DBML Schema Handling - Fantasy Realm Database', () => {
const exported = generateDBMLFromDiagram(diagram);
// For PostgreSQL, keywords should be renamed in export
expect(exported.standardDbml).toContain('Order_field');
expect(exported.standardDbml).toContain('Yes_field');
expect(exported.standardDbml).toContain('No_field');
expect(exported.standardDbml).toContain('Order');
expect(exported.standardDbml).toContain('Yes');
expect(exported.standardDbml).toContain('No');
});
});

View File

@@ -15,6 +15,8 @@ import {
type DBCustomType,
} from '@/lib/domain/db-custom-type';
export const defaultDBMLDiagramName = 'DBML Import';
// Preprocess DBML to handle unsupported features
export const preprocessDBML = (content: string): string => {
let processed = content;
@@ -87,6 +89,7 @@ interface DBMLField {
precision?: number | null;
scale?: number | null;
note?: string | { value: string } | null;
default?: string | null;
}
interface DBMLIndexColumn {
@@ -196,7 +199,7 @@ export const importDBMLToDiagram = async (
if (!dbmlContent.trim()) {
return {
id: generateDiagramId(),
name: 'DBML Import',
name: defaultDBMLDiagramName,
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
tables: [],
relationships: [],
@@ -214,7 +217,7 @@ export const importDBMLToDiagram = async (
if (!sanitizedContent.trim()) {
return {
id: generateDiagramId(),
name: 'DBML Import',
name: defaultDBMLDiagramName,
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
tables: [],
relationships: [],
@@ -229,7 +232,7 @@ export const importDBMLToDiagram = async (
if (!parsedData.schemas || parsedData.schemas.length === 0) {
return {
id: generateDiagramId(),
name: 'DBML Import',
name: defaultDBMLDiagramName,
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
tables: [],
relationships: [],
@@ -332,6 +335,20 @@ 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
);
// Remove ALL quotes (single, double, backticks) to clean the value
// The SQL export layer will handle adding proper quotes when needed
defaultValue = rawDefault.replace(/['"`]/g, '');
}
return {
name: field.name,
type: field.type,
@@ -340,6 +357,7 @@ export const importDBMLToDiagram = async (
not_null: field.not_null,
increment: field.increment,
note: field.note,
default: defaultValue,
...getFieldExtraAttributes(field, allEnums),
} satisfies DBMLField;
}),
@@ -480,12 +498,13 @@ export const importDBMLToDiagram = async (
}),
nullable: !field.not_null,
primaryKey: field.pk || false,
unique: field.unique || false,
unique: field.unique || field.pk || false, // Primary keys are always unique
createdAt: Date.now(),
characterMaximumLength: field.characterMaximumLength,
precision: field.precision,
scale: field.scale,
...(fieldComment ? { comments: fieldComment } : {}),
...(field.default ? { default: field.default } : {}),
};
});
@@ -734,7 +753,7 @@ export const importDBMLToDiagram = async (
return {
id: generateDiagramId(),
name: 'DBML Import',
name: defaultDBMLDiagramName,
databaseType: options?.databaseType ?? DatabaseType.GENERIC,
tables,
relationships,

View File

@@ -0,0 +1,52 @@
import { Parser } from '@dbml/core';
import { preprocessDBML, sanitizeDBML } from './dbml-import';
import type { DBMLError } from './dbml-import-error';
import { parseDBMLError } from './dbml-import-error';
export const verifyDBML = (
content: string
):
| {
hasError: true;
error: unknown;
parsedError?: DBMLError;
errorText: string;
}
| {
hasError: false;
} => {
try {
const preprocessedContent = preprocessDBML(content);
const sanitizedContent = sanitizeDBML(preprocessedContent);
const parser = new Parser();
parser.parse(sanitizedContent, 'dbmlv2');
} catch (e) {
const parsedError = parseDBMLError(e);
if (parsedError) {
return {
hasError: true,
parsedError: parsedError,
error: e,
errorText: parsedError.message,
};
} else {
if (e instanceof Error) {
return {
hasError: true,
error: e,
errorText: e.message,
};
}
return {
hasError: true,
error: e,
errorText: JSON.stringify(e),
};
}
}
return {
hasError: false,
};
};

View File

@@ -0,0 +1,77 @@
import { z } from 'zod';
import type { Area } from '../area';
export type AreaDiffAttribute = keyof Pick<
Area,
'name' | 'color' | 'x' | 'y' | 'width' | 'height'
>;
const areaDiffAttributeSchema: z.ZodType<AreaDiffAttribute> = z.union([
z.literal('name'),
z.literal('color'),
z.literal('x'),
z.literal('y'),
z.literal('width'),
z.literal('height'),
]);
export interface AreaDiffChanged {
object: 'area';
type: 'changed';
areaId: string;
attribute: AreaDiffAttribute;
oldValue?: string | number | null;
newValue?: string | number | null;
}
export const AreaDiffChangedSchema: z.ZodType<AreaDiffChanged> = z.object({
object: z.literal('area'),
type: z.literal('changed'),
areaId: z.string(),
attribute: areaDiffAttributeSchema,
oldValue: z.union([z.string(), z.number(), z.null()]).optional(),
newValue: z.union([z.string(), z.number(), z.null()]).optional(),
});
export interface AreaDiffRemoved {
object: 'area';
type: 'removed';
areaId: string;
}
export const AreaDiffRemovedSchema: z.ZodType<AreaDiffRemoved> = z.object({
object: z.literal('area'),
type: z.literal('removed'),
areaId: z.string(),
});
export interface AreaDiffAdded<T = Area> {
object: 'area';
type: 'added';
areaAdded: T;
}
export const createAreaDiffAddedSchema = <T = Area>(
areaSchema: z.ZodType<T>
): z.ZodType<AreaDiffAdded<T>> => {
return z.object({
object: z.literal('area'),
type: z.literal('added'),
areaAdded: areaSchema,
}) as z.ZodType<AreaDiffAdded<T>>;
};
export type AreaDiff<T = Area> =
| AreaDiffChanged
| AreaDiffRemoved
| AreaDiffAdded<T>;
export const createAreaDiffSchema = <T = Area>(
areaSchema: z.ZodType<T>
): z.ZodType<AreaDiff<T>> => {
return z.union([
AreaDiffChangedSchema,
AreaDiffRemovedSchema,
createAreaDiffAddedSchema(areaSchema),
]) as z.ZodType<AreaDiff<T>>;
};

View File

@@ -0,0 +1,883 @@
import { describe, it, expect } from 'vitest';
import { generateDiff } from '../diff-check';
import type { Diagram } from '@/lib/domain/diagram';
import type { DBTable } from '@/lib/domain/db-table';
import type { DBField } from '@/lib/domain/db-field';
import type { DBIndex } from '@/lib/domain/db-index';
import type { DBRelationship } from '@/lib/domain/db-relationship';
import type { Area } from '@/lib/domain/area';
import { DatabaseType } from '@/lib/domain/database-type';
import type { TableDiffChanged } from '../../table-diff';
import type { FieldDiffChanged } from '../../field-diff';
import type { AreaDiffChanged } from '../../area-diff';
// Helper function to create a mock diagram
function createMockDiagram(overrides?: Partial<Diagram>): Diagram {
return {
id: 'diagram-1',
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [],
relationships: [],
areas: [],
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Helper function to create a mock table
function createMockTable(overrides?: Partial<DBTable>): DBTable {
return {
id: 'table-1',
name: 'users',
fields: [],
indexes: [],
x: 0,
y: 0,
...overrides,
} as DBTable;
}
// Helper function to create a mock field
function createMockField(overrides?: Partial<DBField>): DBField {
return {
id: 'field-1',
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: false,
nullable: true,
unique: false,
...overrides,
} as DBField;
}
// Helper function to create a mock relationship
function createMockRelationship(
overrides?: Partial<DBRelationship>
): DBRelationship {
return {
id: 'rel-1',
sourceTableId: 'table-1',
targetTableId: 'table-2',
sourceFieldId: 'field-1',
targetFieldId: 'field-2',
type: 'one-to-many',
...overrides,
} as DBRelationship;
}
// Helper function to create a mock area
function createMockArea(overrides?: Partial<Area>): Area {
return {
id: 'area-1',
name: 'Main Area',
x: 0,
y: 0,
width: 100,
height: 100,
color: 'blue',
...overrides,
} as Area;
}
describe('generateDiff', () => {
describe('Basic Table Diffing', () => {
it('should detect added tables', () => {
const oldDiagram = createMockDiagram({ tables: [] });
const newDiagram = createMockDiagram({
tables: [createMockTable()],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('table-table-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('added');
expect(result.changedTables.has('table-1')).toBe(true);
});
it('should detect removed tables', () => {
const oldDiagram = createMockDiagram({
tables: [createMockTable()],
});
const newDiagram = createMockDiagram({ tables: [] });
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('table-table-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('removed');
expect(result.changedTables.has('table-1')).toBe(true);
});
it('should detect table name changes', () => {
const oldDiagram = createMockDiagram({
tables: [createMockTable({ name: 'users' })],
});
const newDiagram = createMockDiagram({
tables: [createMockTable({ name: 'customers' })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('table-name-table-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('changed');
expect((diff as TableDiffChanged)?.attribute).toBe('name');
});
it('should detect table position changes', () => {
const oldDiagram = createMockDiagram({
tables: [createMockTable({ x: 0, y: 0 })],
});
const newDiagram = createMockDiagram({
tables: [createMockTable({ x: 100, y: 200 })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
attributes: {
tables: ['name', 'comments', 'color', 'x', 'y'],
},
},
});
expect(result.diffMap.size).toBe(2);
expect(result.diffMap.has('table-x-table-1')).toBe(true);
expect(result.diffMap.has('table-y-table-1')).toBe(true);
});
it('should detect table width changes', () => {
const oldDiagram = createMockDiagram({
tables: [createMockTable({ width: 150 })],
});
const newDiagram = createMockDiagram({
tables: [createMockTable({ width: 250 })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
attributes: {
tables: ['width'],
},
},
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('table-width-table-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('changed');
expect((diff as TableDiffChanged)?.attribute).toBe('width');
expect((diff as TableDiffChanged)?.oldValue).toBe(150);
expect((diff as TableDiffChanged)?.newValue).toBe(250);
});
it('should detect multiple table dimension changes', () => {
const oldDiagram = createMockDiagram({
tables: [createMockTable({ x: 0, y: 0, width: 100 })],
});
const newDiagram = createMockDiagram({
tables: [createMockTable({ x: 50, y: 75, width: 200 })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
attributes: {
tables: ['x', 'y', 'width'],
},
},
});
expect(result.diffMap.size).toBe(3);
expect(result.diffMap.has('table-x-table-1')).toBe(true);
expect(result.diffMap.has('table-y-table-1')).toBe(true);
expect(result.diffMap.has('table-width-table-1')).toBe(true);
const widthDiff = result.diffMap.get('table-width-table-1');
expect(widthDiff?.type).toBe('changed');
expect((widthDiff as TableDiffChanged)?.oldValue).toBe(100);
expect((widthDiff as TableDiffChanged)?.newValue).toBe(200);
});
});
describe('Field Diffing', () => {
it('should detect added fields', () => {
const oldDiagram = createMockDiagram({
tables: [createMockTable({ fields: [] })],
});
const newDiagram = createMockDiagram({
tables: [
createMockTable({
fields: [createMockField()],
}),
],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('field-field-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('added');
expect(result.changedFields.has('field-1')).toBe(true);
});
it('should detect removed fields', () => {
const oldDiagram = createMockDiagram({
tables: [
createMockTable({
fields: [createMockField()],
}),
],
});
const newDiagram = createMockDiagram({
tables: [createMockTable({ fields: [] })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('field-field-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('removed');
});
it('should detect field type changes', () => {
const oldDiagram = createMockDiagram({
tables: [
createMockTable({
fields: [
createMockField({
type: { id: 'integer', name: 'integer' },
}),
],
}),
],
});
const newDiagram = createMockDiagram({
tables: [
createMockTable({
fields: [
createMockField({
type: { id: 'varchar', name: 'varchar' },
}),
],
}),
],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('field-type-field-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('changed');
expect((diff as FieldDiffChanged)?.attribute).toBe('type');
});
});
describe('Relationship Diffing', () => {
it('should detect added relationships', () => {
const oldDiagram = createMockDiagram({ relationships: [] });
const newDiagram = createMockDiagram({
relationships: [createMockRelationship()],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('relationship-rel-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('added');
});
it('should detect removed relationships', () => {
const oldDiagram = createMockDiagram({
relationships: [createMockRelationship()],
});
const newDiagram = createMockDiagram({ relationships: [] });
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('relationship-rel-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('removed');
});
});
describe('Area Diffing', () => {
it('should detect added areas when includeAreas is true', () => {
const oldDiagram = createMockDiagram({ areas: [] });
const newDiagram = createMockDiagram({
areas: [createMockArea()],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeAreas: true,
},
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('area-area-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('added');
expect(result.changedAreas.has('area-1')).toBe(true);
});
it('should not detect area changes when includeAreas is false', () => {
const oldDiagram = createMockDiagram({ areas: [] });
const newDiagram = createMockDiagram({
areas: [createMockArea()],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeAreas: false,
},
});
expect(result.diffMap.size).toBe(0);
});
it('should detect area width changes', () => {
const oldDiagram = createMockDiagram({
areas: [createMockArea({ width: 100 })],
});
const newDiagram = createMockDiagram({
areas: [createMockArea({ width: 200 })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeAreas: true,
attributes: {
areas: ['width'],
},
},
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('area-width-area-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('changed');
expect((diff as AreaDiffChanged)?.attribute).toBe('width');
expect((diff as AreaDiffChanged)?.oldValue).toBe(100);
expect((diff as AreaDiffChanged)?.newValue).toBe(200);
});
it('should detect area height changes', () => {
const oldDiagram = createMockDiagram({
areas: [createMockArea({ height: 100 })],
});
const newDiagram = createMockDiagram({
areas: [createMockArea({ height: 300 })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeAreas: true,
attributes: {
areas: ['height'],
},
},
});
expect(result.diffMap.size).toBe(1);
const diff = result.diffMap.get('area-height-area-1');
expect(diff).toBeDefined();
expect(diff?.type).toBe('changed');
expect((diff as AreaDiffChanged)?.attribute).toBe('height');
expect((diff as AreaDiffChanged)?.oldValue).toBe(100);
expect((diff as AreaDiffChanged)?.newValue).toBe(300);
});
it('should detect multiple area dimension changes', () => {
const oldDiagram = createMockDiagram({
areas: [
createMockArea({ x: 0, y: 0, width: 100, height: 100 }),
],
});
const newDiagram = createMockDiagram({
areas: [
createMockArea({ x: 50, y: 50, width: 200, height: 300 }),
],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeAreas: true,
attributes: {
areas: ['x', 'y', 'width', 'height'],
},
},
});
expect(result.diffMap.size).toBe(4);
expect(result.diffMap.has('area-x-area-1')).toBe(true);
expect(result.diffMap.has('area-y-area-1')).toBe(true);
expect(result.diffMap.has('area-width-area-1')).toBe(true);
expect(result.diffMap.has('area-height-area-1')).toBe(true);
});
});
describe('Custom Matchers', () => {
it('should use custom table matcher to match by name', () => {
const oldDiagram = createMockDiagram({
tables: [createMockTable({ id: 'table-1', name: 'users' })],
});
const newDiagram = createMockDiagram({
tables: [createMockTable({ id: 'table-2', name: 'users' })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
matchers: {
table: (table, tables) =>
tables.find((t) => t.name === table.name),
},
},
});
// Should not detect any changes since tables match by name
expect(result.diffMap.size).toBe(0);
});
it('should detect changes when custom matcher finds no match', () => {
const oldDiagram = createMockDiagram({
tables: [createMockTable({ id: 'table-1', name: 'users' })],
});
const newDiagram = createMockDiagram({
tables: [createMockTable({ id: 'table-2', name: 'customers' })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
matchers: {
table: (table, tables) =>
tables.find((t) => t.name === table.name),
},
},
});
// Should detect both added and removed since names don't match
expect(result.diffMap.size).toBe(2);
expect(result.diffMap.has('table-table-1')).toBe(true); // removed
expect(result.diffMap.has('table-table-2')).toBe(true); // added
});
it('should use custom field matcher to match by name', () => {
const field1 = createMockField({
id: 'field-1',
name: 'email',
nullable: true,
});
const field2 = createMockField({
id: 'field-2',
name: 'email',
nullable: false,
});
const oldDiagram = createMockDiagram({
tables: [createMockTable({ id: 'table-1', fields: [field1] })],
});
const newDiagram = createMockDiagram({
tables: [createMockTable({ id: 'table-1', fields: [field2] })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
matchers: {
field: (field, fields) =>
fields.find((f) => f.name === field.name),
},
},
});
// With name-based matching, field-1 should match field-2 by name
// and detect the nullable change
const nullableChange = result.diffMap.get('field-nullable-field-1');
expect(nullableChange).toBeDefined();
expect(nullableChange?.type).toBe('changed');
expect((nullableChange as FieldDiffChanged)?.attribute).toBe(
'nullable'
);
});
it('should use case-insensitive custom matcher', () => {
const oldDiagram = createMockDiagram({
tables: [createMockTable({ id: 'table-1', name: 'Users' })],
});
const newDiagram = createMockDiagram({
tables: [createMockTable({ id: 'table-2', name: 'users' })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
matchers: {
table: (table, tables) =>
tables.find(
(t) =>
t.name.toLowerCase() ===
table.name.toLowerCase()
),
},
},
});
// With case-insensitive name matching, the tables are matched
// but the name case difference is still detected as a change
expect(result.diffMap.size).toBe(1);
const nameChange = result.diffMap.get('table-name-table-1');
expect(nameChange).toBeDefined();
expect(nameChange?.type).toBe('changed');
expect((nameChange as TableDiffChanged)?.attribute).toBe('name');
expect((nameChange as TableDiffChanged)?.oldValue).toBe('Users');
expect((nameChange as TableDiffChanged)?.newValue).toBe('users');
});
});
describe('Filtering Options', () => {
it('should only check specified change types', () => {
const oldDiagram = createMockDiagram({
tables: [createMockTable({ id: 'table-1', name: 'users' })],
});
const newDiagram = createMockDiagram({
tables: [createMockTable({ id: 'table-2', name: 'products' })],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
changeTypes: {
tables: ['added'], // Only check for added tables
},
},
});
// Should only detect added table (table-2)
const addedTables = Array.from(result.diffMap.values()).filter(
(diff) => diff.type === 'added' && diff.object === 'table'
);
expect(addedTables.length).toBe(1);
// Should not detect removed table (table-1)
const removedTables = Array.from(result.diffMap.values()).filter(
(diff) => diff.type === 'removed' && diff.object === 'table'
);
expect(removedTables.length).toBe(0);
});
it('should only check specified attributes', () => {
const oldDiagram = createMockDiagram({
tables: [
createMockTable({
id: 'table-1',
name: 'users',
color: 'blue',
comments: 'old comment',
}),
],
});
const newDiagram = createMockDiagram({
tables: [
createMockTable({
id: 'table-1',
name: 'customers',
color: 'red',
comments: 'new comment',
}),
],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
attributes: {
tables: ['name'], // Only check name changes
},
},
});
// Should only detect name change
const nameChanges = Array.from(result.diffMap.values()).filter(
(diff) =>
diff.type === 'changed' &&
diff.attribute === 'name' &&
diff.object === 'table'
);
expect(nameChanges.length).toBe(1);
// Should not detect color or comments changes
const otherChanges = Array.from(result.diffMap.values()).filter(
(diff) =>
diff.type === 'changed' &&
(diff.attribute === 'color' ||
diff.attribute === 'comments') &&
diff.object === 'table'
);
expect(otherChanges.length).toBe(0);
});
it('should respect include flags', () => {
const oldDiagram = createMockDiagram({
tables: [
createMockTable({
fields: [createMockField()],
indexes: [{ id: 'idx-1', name: 'idx' } as DBIndex],
}),
],
});
const newDiagram = createMockDiagram({
tables: [
createMockTable({
fields: [],
indexes: [],
}),
],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeFields: false,
includeIndexes: true,
},
});
// Should only detect index removal, not field removal
expect(result.diffMap.has('index-idx-1')).toBe(true);
expect(result.diffMap.has('field-field-1')).toBe(false);
});
});
describe('Complex Scenarios', () => {
it('should detect all dimensional changes for tables and areas', () => {
const oldDiagram = createMockDiagram({
tables: [
createMockTable({
id: 'table-1',
x: 0,
y: 0,
width: 100,
}),
],
areas: [
createMockArea({
id: 'area-1',
x: 0,
y: 0,
width: 200,
height: 150,
}),
],
});
const newDiagram = createMockDiagram({
tables: [
createMockTable({
id: 'table-1',
x: 10,
y: 20,
width: 120,
}),
],
areas: [
createMockArea({
id: 'area-1',
x: 25,
y: 35,
width: 250,
height: 175,
}),
],
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
options: {
includeAreas: true,
attributes: {
tables: ['x', 'y', 'width'],
areas: ['x', 'y', 'width', 'height'],
},
},
});
// Table dimensional changes
expect(result.diffMap.has('table-x-table-1')).toBe(true);
expect(result.diffMap.has('table-y-table-1')).toBe(true);
expect(result.diffMap.has('table-width-table-1')).toBe(true);
// Area dimensional changes
expect(result.diffMap.has('area-x-area-1')).toBe(true);
expect(result.diffMap.has('area-y-area-1')).toBe(true);
expect(result.diffMap.has('area-width-area-1')).toBe(true);
expect(result.diffMap.has('area-height-area-1')).toBe(true);
// Verify the correct values
const tableWidthDiff = result.diffMap.get('table-width-table-1');
expect((tableWidthDiff as TableDiffChanged)?.oldValue).toBe(100);
expect((tableWidthDiff as TableDiffChanged)?.newValue).toBe(120);
const areaWidthDiff = result.diffMap.get('area-width-area-1');
expect((areaWidthDiff as AreaDiffChanged)?.oldValue).toBe(200);
expect((areaWidthDiff as AreaDiffChanged)?.newValue).toBe(250);
const areaHeightDiff = result.diffMap.get('area-height-area-1');
expect((areaHeightDiff as AreaDiffChanged)?.oldValue).toBe(150);
expect((areaHeightDiff as AreaDiffChanged)?.newValue).toBe(175);
});
it('should handle multiple simultaneous changes', () => {
const oldDiagram = createMockDiagram({
tables: [
createMockTable({
id: 'table-1',
name: 'users',
fields: [
createMockField({ id: 'field-1', name: 'id' }),
createMockField({ id: 'field-2', name: 'email' }),
],
}),
createMockTable({
id: 'table-2',
name: 'products',
}),
],
relationships: [createMockRelationship()],
});
const newDiagram = createMockDiagram({
tables: [
createMockTable({
id: 'table-1',
name: 'customers', // Changed name
fields: [
createMockField({ id: 'field-1', name: 'id' }),
// Removed field-2
createMockField({ id: 'field-3', name: 'name' }), // Added field
],
}),
// Removed table-2
createMockTable({
id: 'table-3',
name: 'orders', // Added table
}),
],
relationships: [], // Removed relationship
});
const result = generateDiff({
diagram: oldDiagram,
newDiagram,
});
// Verify all changes are detected
expect(result.diffMap.has('table-name-table-1')).toBe(true); // Table name change
expect(result.diffMap.has('field-field-2')).toBe(true); // Removed field
expect(result.diffMap.has('field-field-3')).toBe(true); // Added field
expect(result.diffMap.has('table-table-2')).toBe(true); // Removed table
expect(result.diffMap.has('table-table-3')).toBe(true); // Added table
expect(result.diffMap.has('relationship-rel-1')).toBe(true); // Removed relationship
});
it('should handle empty diagrams', () => {
const emptyDiagram1 = createMockDiagram();
const emptyDiagram2 = createMockDiagram();
const result = generateDiff({
diagram: emptyDiagram1,
newDiagram: emptyDiagram2,
});
expect(result.diffMap.size).toBe(0);
expect(result.changedTables.size).toBe(0);
expect(result.changedFields.size).toBe(0);
expect(result.changedAreas.size).toBe(0);
});
it('should handle diagrams with undefined collections', () => {
const diagram1 = createMockDiagram({
tables: undefined,
relationships: undefined,
areas: undefined,
});
const diagram2 = createMockDiagram({
tables: [createMockTable({ id: 'table-1' })],
relationships: [createMockRelationship({ id: 'rel-1' })],
areas: [createMockArea({ id: 'area-1' })],
});
const result = generateDiff({
diagram: diagram1,
newDiagram: diagram2,
options: {
includeAreas: true,
},
});
// Should detect all as added
expect(result.diffMap.has('table-table-1')).toBe(true);
expect(result.diffMap.has('relationship-rel-1')).toBe(true);
expect(result.diffMap.has('area-area-1')).toBe(true);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -8,36 +8,43 @@ import type { RelationshipDiff } from './relationship-diff';
import { createRelationshipDiffSchema } from './relationship-diff';
import type { TableDiff } from './table-diff';
import { createTableDiffSchema } from './table-diff';
import type { DBField, DBIndex, DBRelationship, DBTable } from '..';
import type { AreaDiff } from './area-diff';
import { createAreaDiffSchema } from './area-diff';
import type { DBField, DBIndex, DBRelationship, DBTable, Area } from '..';
export type ChartDBDiff<
TTable = DBTable,
TField = DBField,
TIndex = DBIndex,
TRelationship = DBRelationship,
TArea = Area,
> =
| TableDiff<TTable>
| FieldDiff<TField>
| IndexDiff<TIndex>
| RelationshipDiff<TRelationship>;
| RelationshipDiff<TRelationship>
| AreaDiff<TArea>;
export const createChartDBDiffSchema = <
TTable = DBTable,
TField = DBField,
TIndex = DBIndex,
TRelationship = DBRelationship,
TArea = Area,
>(
tableSchema: z.ZodType<TTable>,
fieldSchema: z.ZodType<TField>,
indexSchema: z.ZodType<TIndex>,
relationshipSchema: z.ZodType<TRelationship>
): z.ZodType<ChartDBDiff<TTable, TField, TIndex, TRelationship>> => {
relationshipSchema: z.ZodType<TRelationship>,
areaSchema: z.ZodType<TArea>
): z.ZodType<ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea>> => {
return z.union([
createTableDiffSchema(tableSchema),
createFieldDiffSchema(fieldSchema),
createIndexDiffSchema(indexSchema),
createRelationshipDiffSchema(relationshipSchema),
]) as z.ZodType<ChartDBDiff<TTable, TField, TIndex, TRelationship>>;
createAreaDiffSchema(areaSchema),
]) as z.ZodType<ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea>>;
};
export type DiffMap<
@@ -45,18 +52,21 @@ export type DiffMap<
TField = DBField,
TIndex = DBIndex,
TRelationship = DBRelationship,
> = Map<string, ChartDBDiff<TTable, TField, TIndex, TRelationship>>;
TArea = Area,
> = Map<string, ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea>>;
export type DiffObject<
TTable = DBTable,
TField = DBField,
TIndex = DBIndex,
TRelationship = DBRelationship,
TArea = Area,
> =
| TableDiff<TTable>['object']
| FieldDiff<TField>['object']
| IndexDiff<TIndex>['object']
| RelationshipDiff<TRelationship>['object'];
| RelationshipDiff<TRelationship>['object']
| AreaDiff<TArea>['object'];
type ExtractDiffKind<T> = T extends { object: infer O; type: infer Type }
? T extends { attribute: infer A }
@@ -69,16 +79,18 @@ export type DiffKind<
TField = DBField,
TIndex = DBIndex,
TRelationship = DBRelationship,
> = ExtractDiffKind<ChartDBDiff<TTable, TField, TIndex, TRelationship>>;
TArea = Area,
> = ExtractDiffKind<ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea>>;
export const isDiffOfKind = <
TTable = DBTable,
TField = DBField,
TIndex = DBIndex,
TRelationship = DBRelationship,
TArea = Area,
>(
diff: ChartDBDiff<TTable, TField, TIndex, TRelationship>,
kind: DiffKind<TTable, TField, TIndex, TRelationship>
diff: ChartDBDiff<TTable, TField, TIndex, TRelationship, TArea>,
kind: DiffKind<TTable, TField, TIndex, TRelationship, TArea>
): boolean => {
if ('attribute' in kind) {
return (

View File

@@ -3,13 +3,16 @@ import type { DBTable } from '../db-table';
export type TableDiffAttribute = keyof Pick<
DBTable,
'name' | 'comments' | 'color'
'name' | 'comments' | 'color' | 'x' | 'y' | 'width'
>;
const tableDiffAttributeSchema: z.ZodType<TableDiffAttribute> = z.union([
z.literal('name'),
z.literal('comments'),
z.literal('color'),
z.literal('x'),
z.literal('y'),
z.literal('width'),
]);
export interface TableDiffChanged {
@@ -17,8 +20,8 @@ export interface TableDiffChanged {
type: 'changed';
tableId: string;
attribute: TableDiffAttribute;
oldValue?: string | null;
newValue?: string | null;
oldValue?: string | number | null;
newValue?: string | number | null;
}
export const TableDiffChangedSchema: z.ZodType<TableDiffChanged> = z.object({
@@ -26,8 +29,8 @@ export const TableDiffChangedSchema: z.ZodType<TableDiffChanged> = z.object({
type: z.literal('changed'),
tableId: z.string(),
attribute: tableDiffAttributeSchema,
oldValue: z.string().or(z.null()).optional(),
newValue: z.string().or(z.null()).optional(),
oldValue: z.union([z.string(), z.number(), z.null()]).optional(),
newValue: z.union([z.string(), z.number(), z.null()]).optional(),
});
export interface TableDiffRemoved {

View File

@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import { detectImportMethod } from '../detect-import-method';
describe('detectImportMethod', () => {
describe('DBML detection', () => {
it('should detect DBML with Table definition', () => {
const content = `Table users {
id int [pk]
name varchar
}`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should detect DBML with Ref definition', () => {
const content = `Table posts {
user_id int
}
Ref: posts.user_id > users.id`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should detect DBML with pk attribute', () => {
const content = `id integer [pk]`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should detect DBML with ref attribute', () => {
const content = `user_id int [ref: > users.id]`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should detect DBML with Enum definition', () => {
const content = `Enum status {
active
inactive
}`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should detect DBML with TableGroup', () => {
const content = `TableGroup commerce {
users
orders
}`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should detect DBML with Note', () => {
const content = `Note project_note {
'This is a note about the project'
}`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should prioritize DBML over SQL when both patterns exist', () => {
const content = `CREATE TABLE test (id int);
Table users {
id int [pk]
}`;
expect(detectImportMethod(content)).toBe('dbml');
});
});
describe('SQL DDL detection', () => {
it('should detect CREATE TABLE statement', () => {
const content = `CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(255)
);`;
expect(detectImportMethod(content)).toBe('ddl');
});
it('should detect ALTER TABLE statement', () => {
const content = `ALTER TABLE users ADD COLUMN email VARCHAR(255);`;
expect(detectImportMethod(content)).toBe('ddl');
});
it('should detect DROP TABLE statement', () => {
const content = `DROP TABLE IF EXISTS users;`;
expect(detectImportMethod(content)).toBe('ddl');
});
it('should detect CREATE INDEX statement', () => {
const content = `CREATE INDEX idx_users_email ON users(email);`;
expect(detectImportMethod(content)).toBe('ddl');
});
it('should detect multiple DDL statements', () => {
const content = `CREATE TABLE users (id INT);
CREATE TABLE posts (id INT);
ALTER TABLE posts ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);`;
expect(detectImportMethod(content)).toBe('ddl');
});
it('should detect DDL case-insensitively', () => {
const content = `create table users (id int);`;
expect(detectImportMethod(content)).toBe('ddl');
});
});
describe('JSON detection', () => {
it('should detect JSON object', () => {
const content = `{
"tables": [],
"relationships": []
}`;
expect(detectImportMethod(content)).toBe('query');
});
it('should detect JSON array', () => {
const content = `[
{"name": "users"},
{"name": "posts"}
]`;
expect(detectImportMethod(content)).toBe('query');
});
it('should detect minified JSON', () => {
const content = `{"tables":[],"relationships":[]}`;
expect(detectImportMethod(content)).toBe('query');
});
it('should detect JSON with whitespace', () => {
const content = ` {
"data": true
} `;
expect(detectImportMethod(content)).toBe('query');
});
});
describe('edge cases', () => {
it('should return null for empty content', () => {
expect(detectImportMethod('')).toBeNull();
expect(detectImportMethod(' ')).toBeNull();
expect(detectImportMethod('\n\n')).toBeNull();
});
it('should return null for unrecognized content', () => {
const content = `This is just some random text
that doesn't match any pattern`;
expect(detectImportMethod(content)).toBeNull();
});
it('should handle content with special characters', () => {
const content = `Table users {
name varchar // Special chars: áéíóú
}`;
expect(detectImportMethod(content)).toBe('dbml');
});
it('should handle malformed JSON gracefully', () => {
const content = `{ "incomplete": `;
expect(detectImportMethod(content)).toBeNull();
});
});
});

View File

@@ -0,0 +1,59 @@
import type { ImportMethod } from './import-method';
export const detectImportMethod = (content: string): ImportMethod | null => {
if (!content || content.trim().length === 0) return null;
const upperContent = content.toUpperCase();
// Check for DBML patterns first (case sensitive)
const dbmlPatterns = [
/^Table\s+\w+\s*{/m,
/^Ref:\s*\w+/m,
/^Enum\s+\w+\s*{/m,
/^TableGroup\s+/m,
/^Note\s+\w+\s*{/m,
/\[pk\]/,
/\[ref:\s*[<>-]/,
];
const hasDBMLPatterns = dbmlPatterns.some((pattern) =>
pattern.test(content)
);
if (hasDBMLPatterns) return 'dbml';
// Common SQL DDL keywords
const ddlKeywords = [
'CREATE TABLE',
'ALTER TABLE',
'DROP TABLE',
'CREATE INDEX',
'CREATE VIEW',
'CREATE PROCEDURE',
'CREATE FUNCTION',
'CREATE SCHEMA',
'CREATE DATABASE',
];
// Check for SQL DDL patterns
const hasDDLKeywords = ddlKeywords.some((keyword) =>
upperContent.includes(keyword)
);
if (hasDDLKeywords) return 'ddl';
// Check if it looks like JSON
try {
// Just check structure, don't need full parse for detection
if (
(content.trim().startsWith('{') && content.trim().endsWith('}')) ||
(content.trim().startsWith('[') && content.trim().endsWith(']'))
) {
return 'query';
}
} catch (error) {
// Not valid JSON, might be partial
console.error('Error detecting content type:', error);
}
// If we can't confidently detect, return null
return null;
};

View File

@@ -0,0 +1 @@
export type ImportMethod = 'query' | 'ddl' | 'dbml';

View File

@@ -0,0 +1,687 @@
import { describe, expect, it } from 'vitest';
import { applyIds } from '../apply-ids';
import {
DatabaseType,
DBCustomTypeKind,
type Diagram,
type DBTable,
type DBField,
type DBIndex,
type DBRelationship,
type DBDependency,
type DBCustomType,
} from '../../domain';
describe('applyIds', () => {
const createBaseDiagram = (overrides?: Partial<Diagram>): Diagram => ({
id: 'diagram1',
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
});
const createTable = (overrides: Partial<DBTable>): DBTable => ({
id: 'table-1',
name: 'table',
schema: 'public',
x: 0,
y: 0,
fields: [],
indexes: [],
color: '#000000',
comments: null,
isView: false,
createdAt: Date.now(),
...overrides,
});
const createField = (overrides: Partial<DBField>): DBField => ({
id: 'field-1',
name: 'field',
type: { id: 'integer', name: 'integer' },
primaryKey: false,
nullable: false,
unique: false,
comments: null,
collation: null,
createdAt: Date.now(),
...overrides,
});
const createIndex = (overrides: Partial<DBIndex>): DBIndex => ({
id: 'index-1',
name: 'index',
unique: false,
fieldIds: [],
createdAt: Date.now(),
...overrides,
});
const createRelationship = (
overrides: Partial<DBRelationship>
): DBRelationship => ({
id: 'rel-1',
name: 'relationship',
sourceTableId: 'table-1',
sourceFieldId: 'field-1',
targetTableId: 'table-2',
targetFieldId: 'field-2',
sourceCardinality: 'many',
targetCardinality: 'one',
createdAt: Date.now(),
...overrides,
});
const createDependency = (
overrides: Partial<DBDependency>
): DBDependency => ({
id: 'dep-1',
tableId: 'table-1',
dependentTableId: 'table-2',
createdAt: Date.now(),
...overrides,
});
const createCustomType = (
overrides: Partial<DBCustomType>
): DBCustomType => ({
id: 'type-1',
name: 'custom_type',
schema: 'public',
kind: DBCustomTypeKind.enum,
values: [],
...overrides,
});
describe('table ID mapping', () => {
it('should preserve table IDs when tables match by name and schema', () => {
const sourceDiagram = createBaseDiagram({
tables: [
createTable({
id: 'source-table-1',
name: 'users',
schema: 'public',
}),
createTable({
id: 'source-table-2',
name: 'posts',
schema: 'public',
}),
],
});
const targetDiagram = createBaseDiagram({
tables: [
createTable({
id: 'target-table-1',
name: 'users',
schema: 'public',
x: 100,
y: 100,
color: '#ff0000',
comments: 'Users table',
}),
createTable({
id: 'target-table-2',
name: 'posts',
schema: 'public',
x: 200,
y: 200,
color: '#00ff00',
}),
],
});
const result = applyIds({ sourceDiagram, targetDiagram });
expect(result.tables).toHaveLength(2);
expect(result.tables?.[0].id).toBe('source-table-1');
expect(result.tables?.[0].name).toBe('users');
expect(result.tables?.[0].x).toBe(100); // Should keep target's position
expect(result.tables?.[0].color).toBe('#ff0000'); // Should keep target's color
expect(result.tables?.[1].id).toBe('source-table-2');
expect(result.tables?.[1].name).toBe('posts');
});
it('should keep target table IDs when no matching source table exists', () => {
const sourceDiagram = createBaseDiagram({
tables: [
createTable({
id: 'source-table-1',
name: 'users',
schema: 'public',
}),
],
});
const targetDiagram = createBaseDiagram({
tables: [
createTable({
id: 'target-table-1',
name: 'orders',
schema: 'public',
x: 100,
y: 100,
color: '#ff0000',
}),
],
});
const result = applyIds({ sourceDiagram, targetDiagram });
expect(result.tables).toHaveLength(1);
expect(result.tables?.[0].id).toBe('target-table-1'); // Should keep target ID
expect(result.tables?.[0].name).toBe('orders');
});
});
describe('field ID mapping', () => {
it('should preserve field IDs when fields match by name within the same table', () => {
const sourceDiagram = createBaseDiagram({
tables: [
createTable({
id: 'source-table-1',
name: 'users',
schema: 'public',
fields: [
createField({
id: 'source-field-1',
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
unique: true,
}),
createField({
id: 'source-field-2',
name: 'email',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
nullable: false,
unique: true,
}),
],
}),
],
});
const targetDiagram = createBaseDiagram({
tables: [
createTable({
id: 'target-table-1',
name: 'users',
schema: 'public',
x: 100,
y: 100,
color: '#ff0000',
fields: [
createField({
id: 'target-field-1',
name: 'id',
type: { id: 'bigint', name: 'bigint' },
primaryKey: true,
nullable: false,
unique: true,
comments: 'Primary key',
}),
createField({
id: 'target-field-2',
name: 'email',
type: { id: 'text', name: 'text' },
primaryKey: false,
nullable: true,
unique: false,
}),
],
}),
],
});
const result = applyIds({ sourceDiagram, targetDiagram });
expect(result.tables?.[0].fields).toHaveLength(2);
expect(result.tables?.[0].fields[0].id).toBe('source-field-1');
expect(result.tables?.[0].fields[0].name).toBe('id');
expect(result.tables?.[0].fields[0].type.id).toBe('bigint'); // Should keep target's type
expect(result.tables?.[0].fields[1].id).toBe('source-field-2');
expect(result.tables?.[0].fields[1].name).toBe('email');
expect(result.tables?.[0].fields[1].nullable).toBe(true); // Should keep target's nullable
});
});
describe('index ID mapping', () => {
it('should preserve index IDs and update field references', () => {
const sourceDiagram = createBaseDiagram({
tables: [
createTable({
id: 'source-table-1',
name: 'users',
schema: 'public',
fields: [
createField({
id: 'source-field-1',
name: 'email',
type: { id: 'varchar', name: 'varchar' },
}),
],
indexes: [
createIndex({
id: 'source-index-1',
name: 'idx_email',
unique: true,
fieldIds: ['source-field-1'],
}),
],
}),
],
});
const targetDiagram = createBaseDiagram({
tables: [
createTable({
id: 'target-table-1',
name: 'users',
schema: 'public',
x: 100,
y: 100,
color: '#ff0000',
fields: [
createField({
id: 'target-field-1',
name: 'email',
type: { id: 'text', name: 'text' },
}),
],
indexes: [
createIndex({
id: 'target-index-1',
name: 'idx_email',
unique: false,
fieldIds: ['target-field-1'],
}),
],
}),
],
});
const result = applyIds({ sourceDiagram, targetDiagram });
expect(result.tables?.[0].indexes).toHaveLength(1);
expect(result.tables?.[0].indexes[0].id).toBe('source-index-1');
expect(result.tables?.[0].indexes[0].fieldIds).toEqual([
'source-field-1',
]); // Should update field reference
expect(result.tables?.[0].indexes[0].unique).toBe(false); // Should keep target's unique setting
});
});
describe('relationship ID mapping', () => {
it('should preserve relationship IDs and update table/field references', () => {
const sourceDiagram = createBaseDiagram({
tables: [
createTable({
id: 'source-table-1',
name: 'users',
schema: 'public',
fields: [
createField({
id: 'source-field-1',
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
}),
],
}),
createTable({
id: 'source-table-2',
name: 'posts',
schema: 'public',
fields: [
createField({
id: 'source-field-2',
name: 'user_id',
type: { id: 'integer', name: 'integer' },
}),
],
}),
],
relationships: [
createRelationship({
id: 'source-rel-1',
name: 'fk_posts_users',
sourceTableId: 'source-table-2',
sourceFieldId: 'source-field-2',
targetTableId: 'source-table-1',
targetFieldId: 'source-field-1',
}),
],
});
const targetDiagram = createBaseDiagram({
tables: [
createTable({
id: 'target-table-1',
name: 'users',
schema: 'public',
x: 100,
y: 100,
color: '#ff0000',
fields: [
createField({
id: 'target-field-1',
name: 'id',
type: { id: 'bigint', name: 'bigint' },
primaryKey: true,
unique: true,
}),
],
}),
createTable({
id: 'target-table-2',
name: 'posts',
schema: 'public',
x: 200,
y: 200,
color: '#00ff00',
fields: [
createField({
id: 'target-field-2',
name: 'user_id',
type: { id: 'bigint', name: 'bigint' },
nullable: true,
}),
],
}),
],
relationships: [
createRelationship({
id: 'target-rel-1',
name: 'fk_posts_users',
sourceTableId: 'target-table-2',
sourceFieldId: 'target-field-2',
targetTableId: 'target-table-1',
targetFieldId: 'target-field-1',
}),
],
});
const result = applyIds({ sourceDiagram, targetDiagram });
expect(result.relationships).toHaveLength(1);
expect(result.relationships?.[0].id).toBe('source-rel-1');
expect(result.relationships?.[0].sourceTableId).toBe(
'source-table-2'
);
expect(result.relationships?.[0].sourceFieldId).toBe(
'source-field-2'
);
expect(result.relationships?.[0].targetTableId).toBe(
'source-table-1'
);
expect(result.relationships?.[0].targetFieldId).toBe(
'source-field-1'
);
});
});
describe('dependency ID mapping', () => {
it('should preserve dependency IDs and update table references', () => {
const sourceDiagram = createBaseDiagram({
tables: [
createTable({
id: 'source-table-1',
name: 'users',
schema: 'public',
}),
createTable({
id: 'source-table-2',
name: 'user_view',
schema: 'public',
isView: true,
}),
],
dependencies: [
createDependency({
id: 'source-dep-1',
tableId: 'source-table-2',
dependentTableId: 'source-table-1',
}),
],
});
const targetDiagram = createBaseDiagram({
tables: [
createTable({
id: 'target-table-1',
name: 'users',
schema: 'public',
x: 100,
y: 100,
color: '#ff0000',
}),
createTable({
id: 'target-table-2',
name: 'user_view',
schema: 'public',
x: 200,
y: 200,
color: '#00ff00',
isView: true,
}),
],
dependencies: [
createDependency({
id: 'target-dep-1',
tableId: 'target-table-2',
dependentTableId: 'target-table-1',
}),
],
});
const result = applyIds({ sourceDiagram, targetDiagram });
expect(result.dependencies).toHaveLength(1);
expect(result.dependencies?.[0].id).toBe('source-dep-1');
expect(result.dependencies?.[0].tableId).toBe('source-table-2');
expect(result.dependencies?.[0].dependentTableId).toBe(
'source-table-1'
);
});
});
describe('custom type ID mapping', () => {
it('should preserve custom type IDs when types match by name and schema', () => {
const sourceDiagram = createBaseDiagram({
customTypes: [
createCustomType({
id: 'source-type-1',
name: 'user_role',
schema: 'public',
values: ['admin', 'user', 'guest'],
}),
],
});
const targetDiagram = createBaseDiagram({
customTypes: [
createCustomType({
id: 'target-type-1',
name: 'user_role',
schema: 'public',
values: ['admin', 'user', 'guest', 'moderator'],
}),
],
});
const result = applyIds({ sourceDiagram, targetDiagram });
expect(result.customTypes).toHaveLength(1);
expect(result.customTypes?.[0].id).toBe('source-type-1');
expect(result.customTypes?.[0].values).toEqual([
'admin',
'user',
'guest',
'moderator',
]); // Should keep target's values
});
});
describe('complex scenarios', () => {
it('should handle partial matches correctly', () => {
const sourceDiagram = createBaseDiagram({
tables: [
createTable({
id: 'source-table-1',
name: 'users',
schema: 'public',
fields: [
createField({
id: 'source-field-1',
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
}),
createField({
id: 'source-field-2',
name: 'email',
type: { id: 'varchar', name: 'varchar' },
unique: true,
}),
],
}),
],
});
const targetDiagram = createBaseDiagram({
tables: [
createTable({
id: 'target-table-1',
name: 'users',
schema: 'public',
x: 100,
y: 100,
color: '#ff0000',
fields: [
createField({
id: 'target-field-1',
name: 'id',
type: { id: 'bigint', name: 'bigint' },
primaryKey: true,
unique: true,
}),
createField({
id: 'target-field-3',
name: 'username',
type: { id: 'varchar', name: 'varchar' },
unique: true,
}),
],
}),
createTable({
id: 'target-table-2',
name: 'posts',
schema: 'public',
x: 200,
y: 200,
color: '#00ff00',
}),
],
});
const result = applyIds({ sourceDiagram, targetDiagram });
expect(result.tables).toHaveLength(2);
expect(result.tables?.[0].id).toBe('source-table-1');
expect(result.tables?.[0].fields).toHaveLength(2);
expect(result.tables?.[0].fields[0].id).toBe('source-field-1'); // Matched field
expect(result.tables?.[0].fields[1].id).toBe('target-field-3'); // Unmatched field keeps target ID
expect(result.tables?.[1].id).toBe('target-table-2'); // Unmatched table keeps target ID
});
it('should handle different schemas correctly', () => {
const sourceDiagram = createBaseDiagram({
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: 'source-table-1',
name: 'users',
schema: 'auth',
}),
],
});
const targetDiagram = createBaseDiagram({
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: 'target-table-1',
name: 'users',
schema: 'public',
x: 100,
y: 100,
color: '#ff0000',
}),
],
});
const result = applyIds({ sourceDiagram, targetDiagram });
expect(result.tables?.[0].id).toBe('target-table-1'); // Different schemas, no match
});
it('should handle empty diagrams', () => {
const sourceDiagram = createBaseDiagram();
const targetDiagram = createBaseDiagram();
const result = applyIds({ sourceDiagram, targetDiagram });
expect(result).toEqual(targetDiagram);
});
it('should return target diagram unchanged when source has no matching entities', () => {
const sourceDiagram = createBaseDiagram({
tables: [
createTable({
id: 'source-table-1',
name: 'products',
schema: 'inventory',
}),
],
});
const targetDiagram = createBaseDiagram({
tables: [
createTable({
id: 'target-table-1',
name: 'users',
schema: 'public',
x: 100,
y: 100,
color: '#ff0000',
fields: [
createField({
id: 'target-field-1',
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
}),
],
}),
],
});
const result = applyIds({ sourceDiagram, targetDiagram });
// Should keep all target IDs since nothing matches
expect(result.tables?.[0].id).toBe('target-table-1');
expect(result.tables?.[0].fields[0].id).toBe('target-field-1');
expect(result.tables?.[0].name).toBe('users');
expect(result.tables?.[0].schema).toBe('public');
});
});
});

328
src/lib/utils/apply-ids.ts Normal file
View File

@@ -0,0 +1,328 @@
import { defaultSchemas } from '../data/default-schemas';
import type { DBCustomType, DBTable, Diagram } from '../domain';
const createTableKey = ({
table,
defaultSchema,
}: {
table: DBTable;
defaultSchema?: string;
}) => {
return `${table.schema ?? defaultSchema ?? ''}::${table.name}`;
};
const createFieldKey = ({
table,
fieldName,
defaultSchema,
}: {
table: DBTable;
fieldName: string;
defaultSchema?: string;
}) => {
return `${table.schema ?? defaultSchema ?? ''}::${table.name}::${fieldName}`;
};
const createIndexKey = ({
table,
indexName,
defaultSchema,
}: {
table: DBTable;
indexName: string;
defaultSchema?: string;
}) => {
return `${table.schema ?? defaultSchema ?? ''}::${table.name}::${indexName}`;
};
const createRelationshipKey = ({
relationshipName,
defaultSchema,
}: {
relationshipName: string;
defaultSchema?: string;
}) => {
return `${defaultSchema ?? ''}::${relationshipName}`;
};
const createDependencyKey = ({
table,
dependentTable,
defaultSchema,
}: {
table: DBTable;
dependentTable: DBTable;
defaultSchema?: string;
}) => {
return `${table.schema ?? defaultSchema ?? ''}::${table.name}::${dependentTable.schema ?? defaultSchema ?? ''}::${dependentTable.name}`;
};
const createCustomTypeKey = ({
customType,
defaultSchema,
}: {
customType: DBCustomType;
defaultSchema?: string;
}) => {
return `${customType.schema ?? defaultSchema ?? ''}::${customType.name}`;
};
export const applyIds = ({
sourceDiagram,
targetDiagram,
}: {
sourceDiagram: Diagram;
targetDiagram: Diagram;
}): Diagram => {
// Create a mapping of old IDs to new IDs
const tablesIdMapping = new Map<string, string>();
const fieldsIdMapping = new Map<string, string>();
const indexesIdMapping = new Map<string, string>();
const relationshipsIdMapping = new Map<string, string>();
const dependenciesIdMapping = new Map<string, string>();
const customTypesIdMapping = new Map<string, string>();
const sourceDefaultSchema = defaultSchemas[sourceDiagram.databaseType];
const targetDefaultSchema = defaultSchemas[targetDiagram.databaseType];
// build idMapping
sourceDiagram?.tables?.forEach((sourceTable) => {
const sourceKey = createTableKey({
table: sourceTable,
defaultSchema: sourceDefaultSchema,
});
tablesIdMapping.set(sourceKey, sourceTable.id);
sourceTable.fields.forEach((field) => {
const fieldKey = createFieldKey({
table: sourceTable,
fieldName: field.name,
defaultSchema: sourceDefaultSchema,
});
fieldsIdMapping.set(fieldKey, field.id);
});
sourceTable.indexes.forEach((index) => {
const indexKey = createIndexKey({
table: sourceTable,
indexName: index.name,
defaultSchema: sourceDefaultSchema,
});
indexesIdMapping.set(indexKey, index.id);
});
});
sourceDiagram.relationships?.forEach((relationship) => {
const relationshipKey = createRelationshipKey({
relationshipName: relationship.name,
defaultSchema: sourceDefaultSchema,
});
relationshipsIdMapping.set(relationshipKey, relationship.id);
});
sourceDiagram.dependencies?.forEach((dependency) => {
const table = sourceDiagram.tables?.find(
(t) => t.id === dependency.tableId
);
const dependentTable = sourceDiagram.tables?.find(
(t) => t.id === dependency.dependentTableId
);
if (!table || !dependentTable) return;
const dependencyKey = createDependencyKey({
table,
dependentTable,
defaultSchema: sourceDefaultSchema,
});
dependenciesIdMapping.set(dependencyKey, dependency.id);
});
sourceDiagram.customTypes?.forEach((customType) => {
const customTypeKey = createCustomTypeKey({
customType,
defaultSchema: sourceDefaultSchema,
});
customTypesIdMapping.set(customTypeKey, customType.id);
});
// Map current ID -> new ID for target diagram entities
const targetTableIdMapping = new Map<string, string>();
const targetFieldIdMapping = new Map<string, string>();
const targetIndexIdMapping = new Map<string, string>();
const targetRelationshipIdMapping = new Map<string, string>();
const targetDependencyIdMapping = new Map<string, string>();
const targetCustomTypeIdMapping = new Map<string, string>();
targetDiagram?.tables?.forEach((targetTable) => {
const targetKey = createTableKey({
table: targetTable,
defaultSchema: targetDefaultSchema,
});
const newId = tablesIdMapping.get(targetKey);
if (newId) {
targetTableIdMapping.set(targetTable.id, newId);
}
targetTable.fields.forEach((field) => {
const fieldKey = createFieldKey({
table: targetTable,
fieldName: field.name,
defaultSchema: targetDefaultSchema,
});
const newFieldId = fieldsIdMapping.get(fieldKey);
if (newFieldId) {
targetFieldIdMapping.set(field.id, newFieldId);
}
});
targetTable.indexes.forEach((index) => {
const indexKey = createIndexKey({
table: targetTable,
indexName: index.name,
defaultSchema: targetDefaultSchema,
});
const newIndexId = indexesIdMapping.get(indexKey);
if (newIndexId) {
targetIndexIdMapping.set(index.id, newIndexId);
}
});
});
targetDiagram.relationships?.forEach((relationship) => {
const relationshipKey = createRelationshipKey({
relationshipName: relationship.name,
defaultSchema: targetDefaultSchema,
});
const newId = relationshipsIdMapping.get(relationshipKey);
if (newId) {
targetRelationshipIdMapping.set(relationship.id, newId);
}
});
targetDiagram.dependencies?.forEach((dependency) => {
const table = targetDiagram.tables?.find(
(t) => t.id === dependency.tableId
);
const dependentTable = targetDiagram.tables?.find(
(t) => t.id === dependency.dependentTableId
);
if (!table || !dependentTable) return;
const dependencyKey = createDependencyKey({
table,
dependentTable,
defaultSchema: targetDefaultSchema,
});
const newId = dependenciesIdMapping.get(dependencyKey);
if (newId) {
targetDependencyIdMapping.set(dependency.id, newId);
}
});
targetDiagram.customTypes?.forEach((customType) => {
const customTypeKey = createCustomTypeKey({
customType,
defaultSchema: targetDefaultSchema,
});
const newId = customTypesIdMapping.get(customTypeKey);
if (newId) {
targetCustomTypeIdMapping.set(customType.id, newId);
}
});
// Apply the ID mappings to create the final diagram
const result: Diagram = {
...targetDiagram,
tables: targetDiagram.tables?.map((table) => {
const newTableId = targetTableIdMapping.get(table.id) ?? table.id;
return {
...table,
id: newTableId,
fields: table.fields.map((field) => {
const newFieldId =
targetFieldIdMapping.get(field.id) ?? field.id;
return {
...field,
id: newFieldId,
};
}),
indexes: table.indexes.map((index) => {
const newIndexId =
targetIndexIdMapping.get(index.id) ?? index.id;
// Update field IDs in index
const updatedFieldIds = index.fieldIds.map((fieldId) => {
return targetFieldIdMapping.get(fieldId) ?? fieldId;
});
return {
...index,
id: newIndexId,
fieldIds: updatedFieldIds,
};
}),
};
}),
relationships: targetDiagram.relationships?.map((relationship) => {
const newRelationshipId =
targetRelationshipIdMapping.get(relationship.id) ??
relationship.id;
// Update table and field IDs in relationships
const newSourceTableId =
targetTableIdMapping.get(relationship.sourceTableId) ??
relationship.sourceTableId;
const newTargetTableId =
targetTableIdMapping.get(relationship.targetTableId) ??
relationship.targetTableId;
const newSourceFieldId =
targetFieldIdMapping.get(relationship.sourceFieldId) ??
relationship.sourceFieldId;
const newTargetFieldId =
targetFieldIdMapping.get(relationship.targetFieldId) ??
relationship.targetFieldId;
return {
...relationship,
id: newRelationshipId,
sourceTableId: newSourceTableId,
targetTableId: newTargetTableId,
sourceFieldId: newSourceFieldId,
targetFieldId: newTargetFieldId,
};
}),
dependencies: targetDiagram.dependencies?.map((dependency) => {
const newDependencyId =
targetDependencyIdMapping.get(dependency.id) ?? dependency.id;
const newTableId =
targetTableIdMapping.get(dependency.tableId) ??
dependency.tableId;
const newDependentTableId =
targetTableIdMapping.get(dependency.dependentTableId) ??
dependency.dependentTableId;
return {
...dependency,
id: newDependencyId,
tableId: newTableId,
dependentTableId: newDependentTableId,
};
}),
customTypes: targetDiagram.customTypes?.map((customType) => {
const newCustomTypeId =
targetCustomTypeIdMapping.get(customType.id) ?? customType.id;
return {
...customType,
id: newCustomTypeId,
};
}),
};
return result;
};

View File

@@ -1,13 +1,13 @@
import type { DBTable } from '@/lib/domain/db-table';
import type { Area } from '@/lib/domain/area';
import { calcTableHeight } from '@/lib/domain/db-table';
import { calcTableHeight, MIN_TABLE_SIZE } from '@/lib/domain/db-table';
/**
* Check if a table is inside an area based on their positions and dimensions
*/
const isTableInsideArea = (table: DBTable, area: Area): boolean => {
export const isTableInsideArea = (table: DBTable, area: Area): boolean => {
// Get table dimensions (assuming default width if not specified)
const tableWidth = table.width ?? 224; // MIN_TABLE_SIZE from db-table.ts
const tableWidth = table.width ?? MIN_TABLE_SIZE;
const tableHeight = calcTableHeight(table);
// Check if table's top-left corner is inside the area
@@ -33,7 +33,10 @@ const isTableInsideArea = (table: DBTable, area: Area): boolean => {
/**
* Find which area contains a table
*/
const findContainingArea = (table: DBTable, areas: Area[]): Area | null => {
export const findContainingArea = (
table: DBTable,
areas: Area[]
): Area | null => {
// Sort areas by order (if available) to prioritize top-most areas
const sortedAreas = [...areas].sort(
(a, b) => (b.order ?? 0) - (a.order ?? 0)

3
src/lib/utils/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './utils';
export * from './apply-ids';
export * from './area-utils';

View File

@@ -0,0 +1,54 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/context-menu/context-menu';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useChartDB } from '@/hooks/use-chartdb';
import type { Area } from '@/lib/domain/area';
import { Pencil, Trash2 } from 'lucide-react';
import React, { useCallback } from 'react';
export interface AreaNodeContextMenuProps {
area: Area;
onEditName?: () => void;
}
export const AreaNodeContextMenu: React.FC<
React.PropsWithChildren<AreaNodeContextMenuProps>
> = ({ children, area, onEditName }) => {
const { removeArea, readonly } = useChartDB();
const { isMd: isDesktop } = useBreakpoint('md');
const removeAreaHandler = useCallback(() => {
removeArea(area.id);
}, [removeArea, area.id]);
if (!isDesktop || readonly) {
return <>{children}</>;
}
return (
<ContextMenu>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>
{onEditName ? (
<ContextMenuItem
onClick={onEditName}
className="flex justify-between gap-3"
>
<span>Edit Area Name</span>
<Pencil className="size-3.5" />
</ContextMenuItem>
) : null}
<ContextMenuItem
onClick={removeAreaHandler}
className="flex justify-between gap-3"
>
<span>Delete Area</span>
<Trash2 className="size-3.5 text-red-700" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};

View File

@@ -12,9 +12,11 @@ import {
} from '@/components/tooltip/tooltip';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Check, GripVertical } from 'lucide-react';
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<
{
@@ -56,85 +58,102 @@ 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);
};
return (
<div
className={cn(
'flex h-full flex-col rounded-md border-2 shadow-sm',
selected ? 'border-pink-600' : 'border-transparent'
)}
style={{
backgroundColor: `${area.color}15`,
borderColor: selected ? undefined : area.color,
}}
onClick={(e) => {
if (e.detail === 2) {
openAreaInEditor();
}
}}
<AreaNodeContextMenu
area={area}
onEditName={() => setEditMode(true)}
>
{!readonly ? (
<NodeResizer
isVisible={focused}
lineClassName="!border-4 !border-transparent"
handleClassName="!h-[10px] !w-[10px] !rounded-full !bg-pink-600"
minHeight={100}
minWidth={100}
/>
) : null}
<div className="group flex h-8 items-center justify-between rounded-t-md px-2">
<div className="flex w-full items-center gap-1">
<GripVertical className="size-4 shrink-0 text-slate-700 opacity-60 dark:text-slate-300" />
<div
className={cn(
'flex h-full flex-col rounded-md border-2 shadow-sm',
selected ? 'border-pink-600' : 'border-transparent'
)}
style={{
backgroundColor: `${area.color}15`,
borderColor: selected ? undefined : area.color,
}}
onClick={(e) => {
setEditTableModeTable(null);
if (e.detail === 2) {
openAreaInEditor();
}
}}
>
{!readonly ? (
<NodeResizer
isVisible={focused}
lineClassName="!border-4 !border-transparent"
handleClassName="!h-[10px] !w-[10px] !rounded-full !bg-pink-600"
minHeight={100}
minWidth={100}
/>
) : null}
<div className="group flex h-8 items-center justify-between rounded-t-md px-2">
<div className="flex w-full items-center gap-1">
<GripVertical className="size-4 shrink-0 text-slate-700 opacity-60 dark:text-slate-300" />
{editMode && !readonly ? (
<div className="flex w-full items-center">
<Input
ref={inputRef}
autoFocus
type="text"
placeholder={area.name}
value={areaName}
onClick={(e) => e.stopPropagation()}
onChange={(e) =>
setAreaName(e.target.value)
}
className="h-6 bg-white/70 focus-visible:ring-0 dark:bg-slate-900/70"
/>
{editMode && !readonly ? (
<div className="flex w-full items-center">
<Input
ref={inputRef}
autoFocus
type="text"
placeholder={area.name}
value={areaName}
onClick={(e) => e.stopPropagation()}
onChange={(e) =>
setAreaName(e.target.value)
}
className="h-6 bg-white/70 focus-visible:ring-0 dark:bg-slate-900/70"
/>
<Button
variant="ghost"
className="ml-1 size-6 p-0 hover:bg-white/20"
onClick={editAreaName}
>
<Check className="size-3.5 text-slate-700 dark:text-slate-300" />
</Button>
</div>
) : !readonly ? (
<Tooltip>
<TooltipTrigger asChild>
<div
className="text-editable truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300"
onDoubleClick={enterEditMode}
>
{area.name}
</div>
</TooltipTrigger>
<TooltipContent>
{t('tool_tips.double_click_to_edit')}
</TooltipContent>
</Tooltip>
) : (
<div className="truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300">
{area.name}
</div>
)}
{!editMode && !readonly && (
<Button
variant="ghost"
className="ml-1 size-6 p-0 hover:bg-white/20"
onClick={editAreaName}
className="ml-auto size-5 p-0 opacity-0 transition-opacity hover:bg-white/20 group-hover:opacity-100"
onClick={enterEditMode}
>
<Check className="size-3.5 text-slate-700 dark:text-slate-300" />
<Pencil className="size-3 text-slate-700 dark:text-slate-300" />
</Button>
</div>
) : !readonly ? (
<Tooltip>
<TooltipTrigger asChild>
<div
className="text-editable max-w-[200px] cursor-text truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300"
onDoubleClick={enterEditMode}
>
{area.name}
</div>
</TooltipTrigger>
<TooltipContent>
{t('tool_tips.double_click_to_edit')}
</TooltipContent>
</Tooltip>
) : (
<div className="truncate px-1 py-0.5 text-base font-semibold text-slate-700 dark:text-slate-300">
{area.name}
</div>
)}
)}
</div>
</div>
<div className="flex-1" />
</div>
<div className="flex-1" />
</div>
</AreaNodeContextMenu>
);
}
);

View File

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

View File

@@ -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,7 +83,24 @@ 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 { updateTablesParentAreas, getTablesInArea } from './area-utils';
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,
} from '@/lib/utils/area-utils';
import { CanvasFilter } from './canvas-filter/canvas-filter';
import { useHotkeys } from 'react-hotkeys-hook';
import { ShowAllButton } from './show-all-button';
@@ -89,24 +110,35 @@ import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-f
import { filterTable } from '@/lib/domain/diagram-filter/filter';
import { defaultSchemas } from '@/lib/data/default-schemas';
import { useDiff } from '@/context/diff-context/use-diff';
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[] = [];
@@ -119,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
@@ -152,6 +186,7 @@ const tableToTableNode = (
data: {
table,
isOverlapping: false,
isRelationshipCreatingTarget,
},
width: table.width ?? MIN_TABLE_SIZE,
hidden,
@@ -196,6 +231,9 @@ const areaToAreaNode = (
width: area.width,
height: area.height,
zIndex: -10,
style: {
zIndex: -10,
},
hidden: !hasVisibleTable || filterLoading,
};
};
@@ -244,6 +282,11 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
overlapGraph,
showFilter,
setShowFilter,
setEditTableModeTable,
tempFloatingEdge,
endFloatingEdgeCreation,
hoveringTableId,
hideCreateRelationshipNode,
} = useCanvas();
const { filter, loading: filterLoading } = useDiagramFilter();
const { checkIfNewTable } = useDiff();
@@ -265,6 +308,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
forceShow: shouldForceShowTable(table.id),
isRelationshipCreatingTarget: false,
})
)
);
@@ -273,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]);
@@ -285,6 +334,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
filterLoading,
showDBViews,
forceShow: shouldForceShowTable(table.id),
isRelationshipCreatingTarget: false,
})
);
if (equal(initialNodes, nodes)) {
@@ -390,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;
});
@@ -459,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
@@ -488,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
@@ -512,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)) {
@@ -1213,6 +1304,14 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
setTimeout(() => setHighlightOverlappingTables(false), 600);
}, []);
const containerRef = useRef<HTMLDivElement>(null);
const exitEditTableMode = useCallback(
() => setEditTableModeTable(null),
[setEditTableModeTable]
);
useClickAway(containerRef, exitEditTableMode);
useClickAway(containerRef, hideCreateRelationshipNode);
const shiftPressed = useKeyPress('Shift');
const operatingSystem = getOperatingSystem();
@@ -1228,15 +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">
<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}
@@ -1255,6 +1473,8 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
panOnScroll={scrollAction === 'pan'}
snapToGrid={shiftPressed || snapToGridEnabled}
snapGrid={[20, 20]}
onPaneClick={onPaneClickHandler}
connectionLineComponent={ConnectionLine}
>
<Controls
position="top-left"

View File

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

View File

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

View File

@@ -0,0 +1,180 @@
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 { useUpdateTableField } from '@/hooks/use-update-table-field';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip/tooltip';
import { useTranslation } from 'react-i18next';
import { SelectBox } from '@/components/select-box/select-box';
import { cn } from '@/lib/utils';
import { TableFieldToggle } from './table-field-toggle';
export interface TableEditModeFieldProps {
table: DBTable;
field: DBField;
focused?: boolean;
}
export const TableEditModeField: React.FC<TableEditModeFieldProps> = React.memo(
({ table, field, focused = false }) => {
const { t } = useTranslation();
const [showHighlight, setShowHighlight] = React.useState(false);
const {
dataFieldOptions,
handleDataTypeChange,
handlePrimaryKeyToggle,
handleNullableToggle,
handleNameChange,
generateFieldSuffix,
fieldName,
nullable,
primaryKey,
removeField,
} = useUpdateTableField(table, field);
const inputRef = React.useRef<HTMLInputElement>(null);
// Animate the highlight after mount if focused
useEffect(() => {
if (focused) {
const timer = setTimeout(() => {
setShowHighlight(true);
inputRef.current?.select();
setTimeout(() => {
setShowHighlight(false);
}, 2000);
}, 200); // Small delay for the animation to be noticeable
return () => clearTimeout(timer);
} else {
setShowHighlight(false);
}
}, [focused]);
return (
<div
className={cn(
'flex flex-1 flex-row justify-between gap-2 p-1 transition-colors duration-1000 ease-out',
{
'bg-sky-100 dark:bg-sky-950': showHighlight,
}
)}
>
<div className="flex flex-1 items-center justify-start gap-1 overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1">
<Input
ref={inputRef}
className="h-8 w-full !truncate bg-background focus-visible:ring-0"
type="text"
placeholder={t(
'side_panel.tables_section.table.field_name'
)}
value={fieldName}
onChange={(e) =>
handleNameChange(e.target.value)
}
autoFocus={focused}
/>
</span>
</TooltipTrigger>
<TooltipContent>{fieldName}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
className="flex h-8 min-w-0 flex-1"
asChild
>
<span>
<SelectBox
className="flex h-8 min-h-8 w-full bg-background"
popoverClassName="min-w-[200px]"
options={dataFieldOptions}
placeholder={t(
'side_panel.tables_section.table.field_type'
)}
value={field.type.id}
valueSuffix={generateDBFieldSuffix(field)}
optionSuffix={(option) =>
generateFieldSuffix(option.value)
}
onChange={handleDataTypeChange}
emptyPlaceholder={t(
'side_panel.tables_section.table.no_types_found'
)}
commandOnClick={(e) => e.stopPropagation()}
commandOnMouseDown={(e) =>
e.stopPropagation()
}
/>
</span>
</TooltipTrigger>
<TooltipContent>
{field.type.name}
{field.characterMaximumLength
? `(${field.characterMaximumLength})`
: ''}
</TooltipContent>
</Tooltip>
</div>
<div className="flex shrink-0 items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span>
<TableFieldToggle
pressed={nullable}
onPressedChange={handleNullableToggle}
>
N
</TableFieldToggle>
</span>
</TooltipTrigger>
<TooltipContent>
{t('side_panel.tables_section.table.nullable')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<TableFieldToggle
pressed={primaryKey}
onPressedChange={handlePrimaryKeyToggle}
>
<KeyRound className="h-3.5" />
</TableFieldToggle>
</span>
</TooltipTrigger>
<TooltipContent>
{t('side_panel.tables_section.table.primary_key')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<TableFieldToggle onPressedChange={removeField}>
<Trash2 className="h-3.5 text-red-700" />
</TableFieldToggle>
</span>
</TooltipTrigger>
<TooltipContent>
{t(
'side_panel.tables_section.table.field_actions.delete_field'
)}
</TooltipContent>
</Tooltip>
</div>
</div>
);
}
);
TableEditModeField.displayName = 'TableEditModeField';

View File

@@ -0,0 +1,344 @@
import { Input } from '@/components/input/input';
import type { DBTable } from '@/lib/domain';
import { FileType2, X, SquarePlus } 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';
import { Button } from '@/components/button/button';
import { ColorPicker } from '@/components/color-picker/color-picker';
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 { 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;
color: string;
focusFieldId?: string;
onClose: () => void;
}
export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
({ table, color, focusFieldId: focusFieldIdProp, onClose }) => {
const containerRef = useRef<HTMLDivElement>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const fieldRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [isVisible, setIsVisible] = useState(false);
const { createField, updateTable, schemas, databaseType } =
useChartDB();
const { t } = useTranslation();
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) {
inputRef.current?.select();
}
}, [focusFieldIdProp]);
// Callback to store field refs
const setFieldRef = useCallback((fieldId: string) => {
return (element: HTMLDivElement | null) => {
if (element) {
fieldRefs.current.set(fieldId, element);
} else {
fieldRefs.current.delete(fieldId);
}
};
}, []);
useEffect(() => {
// Trigger animation after mount
requestAnimationFrame(() => {
setIsVisible(true);
});
}, []);
const scrollToFieldId = useCallback((fieldId: string) => {
const fieldElement = fieldRefs.current.get(fieldId);
if (fieldElement) {
fieldElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}, []);
// Scroll to focused field when component mounts
useEffect(() => {
if (focusFieldId) {
scrollToFieldId(focusFieldId);
}
}, [focusFieldId, scrollToFieldId]);
// Handle wheel events: allow zoom to pass through, but handle scroll locally
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
// If Ctrl or Cmd is pressed, it's a zoom gesture - let it pass through to canvas
if (e.ctrlKey || e.metaKey) {
return;
}
// Otherwise, it's a scroll - stop propagation to prevent canvas panning
e.stopPropagation();
};
const scrollArea = scrollAreaRef.current;
if (scrollArea) {
// Use passive: false to allow preventDefault if needed
scrollArea.addEventListener('wheel', handleWheel, {
passive: false,
});
return () => {
scrollArea.removeEventListener('wheel', handleWheel);
};
}
}, []);
const handleAddField = useCallback(async () => {
const field = await createField(table.id);
if (field.id) {
setFocusFieldId(field.id);
}
}, [createField, table.id]);
const handleColorChange = useCallback(
(newColor: string) => {
updateTable(table.id, { color: newColor });
},
[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]);
return (
<div
ref={containerRef}
className={cn(
'flex z-50 border-slate-500 dark:border-slate-700 flex-col border-2 bg-slate-50 dark:bg-slate-950 rounded-lg shadow-lg absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-auto transition-all duration-100 ease-out',
{
'opacity-100 scale-100': isVisible,
'opacity-0 scale-95': !isVisible,
}
)}
style={{
minHeight: '300px',
minWidth: '350px',
height: 'max(calc(100% + 48px), 200px)',
width: 'max(calc(100% + 48px), 300px)',
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="h-2 cursor-move rounded-t-[6px]"
style={{ backgroundColor: color }}
></div>
<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">
{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"
placeholder="Table name"
value={tableName}
onChange={(e) =>
handleTableNameChange(e.target.value)
}
/>
</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>
<ScrollArea ref={scrollAreaRef} className="nodrag flex-1 p-2">
{table.fields.map((field) => (
<div key={field.id} ref={setFieldRef(field.id)}>
<TableEditModeField
table={table}
field={field}
focused={focusFieldId === field.id}
/>
</div>
))}
</ScrollArea>
<Separator />
<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')}
</span>
</div>
</div>
);
}
);
TableEditMode.displayName = 'TableEditMode';

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { Toggle } from '@/components/toggle/toggle';
export const TableFieldToggle = React.forwardRef<
React.ElementRef<typeof Toggle>,
React.ComponentPropsWithoutRef<typeof Toggle>
>((props, ref) => {
return (
<Toggle
{...props}
ref={ref}
variant="default"
className="h-8 w-[32px] p-2 text-xs text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
/>
);
});
TableFieldToggle.displayName = Toggle.displayName;

View File

@@ -12,7 +12,7 @@ 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 {
table: DBTable;
@@ -22,34 +22,59 @@ export const TableNodeContextMenu: React.FC<
React.PropsWithChildren<TableNodeContextMenuProps>
> = ({ children, table }) => {
const { removeTable, readonly, createTable } = useChartDB();
const { openTableFromSidebar } = useLayout();
const { closeAllTablesInSidebar } = useLayout();
const { t } = useTranslation();
const { isMd: isDesktop } = useBreakpoint('md');
const { openCreateRelationshipDialog } = useDialog();
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(() => {
openTableFromSidebar(table.id);
}, [openTableFromSidebar, table.id]);
const editTableHandler: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.stopPropagation();
if (readonly) {
return;
}
const removeTableHandler = useCallback(() => {
removeTable(table.id);
}, [removeTable, table.id]);
closeAllTablesInSidebar();
setEditTableModeTable({ tableId: table.id });
},
[table.id, setEditTableModeTable, closeAllTablesInSidebar, readonly]
);
const addRelationshipHandler = useCallback(() => {
openCreateRelationshipDialog({
sourceTableId: table.id,
});
}, [openCreateRelationshipDialog, table.id]);
const removeTableHandler: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.stopPropagation();
removeTable(table.id);
},
[removeTable, table.id]
);
const addRelationshipHandler: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.stopPropagation();
startFloatingEdgeCreation({
sourceNodeId: table.id,
});
},
[startFloatingEdgeCreation, table.id]
);
if (!isDesktop || readonly) {
return <>{children}</>;

View File

@@ -13,13 +13,12 @@ import {
} from '@xyflow/react';
import { Button } from '@/components/button/button';
import {
Check,
KeyRound,
MessageCircleMore,
SquareDot,
SquareMinus,
SquarePlus,
Trash2,
Pencil,
} from 'lucide-react';
import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field';
import { useChartDB } from '@/hooks/use-chartdb';
@@ -29,14 +28,14 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip/tooltip';
import { useClickAway, useKeyPressEvent } from 'react-use';
import { Input } from '@/components/input/input';
import { useDiff } from '@/context/diff-context/use-diff';
import { useLocalConfig } from '@/hooks/use-local-config';
import {
BOTTOM_SOURCE_HANDLE_ID_PREFIX,
TOP_SOURCE_HANDLE_ID_PREFIX,
} from './table-node-dependency-indicator';
import { useCanvas } from '@/hooks/use-canvas';
import { useLayout } from '@/hooks/use-layout';
export const LEFT_HANDLE_ID_PREFIX = 'left_rel_';
export const RIGHT_HANDLE_ID_PREFIX = 'right_rel_';
@@ -78,16 +77,7 @@ const arePropsEqual = (
export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
({ field, focused, tableNodeId, highlighted, visible, isConnectable }) => {
const {
removeField,
relationships,
readonly,
updateField,
highlightedCustomType,
} = useChartDB();
const [editMode, setEditMode] = useState(false);
const [fieldName, setFieldName] = useState(field.name);
const inputRef = React.useRef<HTMLInputElement>(null);
const { relationships, readonly, highlightedCustomType } = useChartDB();
const updateNodeInternals = useUpdateNodeInternals();
const connection = useConnection();
@@ -152,23 +142,6 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
}
}, [tableNodeId, updateNodeInternals, numberOfEdgesToField]);
const editFieldName = useCallback(() => {
if (!editMode) return;
if (fieldName.trim()) {
updateField(tableNodeId, field.id, { name: fieldName.trim() });
}
setEditMode(false);
}, [fieldName, field.id, updateField, editMode, tableNodeId]);
const abortEdit = useCallback(() => {
setEditMode(false);
setFieldName(field.name);
}, [field.name]);
useClickAway(inputRef, editFieldName);
useKeyPressEvent('Enter', editFieldName);
useKeyPressEvent('Escape', abortEdit);
const {
checkIfFieldRemoved,
checkIfNewField,
@@ -186,13 +159,17 @@ 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
>;
isDiffFieldChanged: boolean;
}>({
isDiffFieldRemoved: false,
@@ -272,17 +249,32 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
fieldDiffChangedPrecision,
} = diffState;
const enterEditMode = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setEditMode(true);
}, []);
const isCustomTypeHighlighted = useMemo(() => {
if (!highlightedCustomType) return false;
return field.type.name === highlightedCustomType.name;
}, [highlightedCustomType, field.type.name]);
const { showFieldAttributes } = useLocalConfig();
const { closeAllTablesInSidebar } = useLayout();
const { setEditTableModeTable } = useCanvas();
const openEditTableOnField = useCallback(() => {
if (readonly) {
return;
}
closeAllTablesInSidebar();
setEditTableModeTable({
tableId: tableNodeId,
fieldId: field.id,
});
}, [
setEditTableModeTable,
closeAllTablesInSidebar,
tableNodeId,
field.id,
readonly,
]);
return (
<div
className={cn(
@@ -354,7 +346,6 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
'flex items-center gap-1 min-w-0 flex-1 text-left',
{
'font-semibold': field.primaryKey || field.unique,
'w-full': editMode,
}
)}
>
@@ -365,54 +356,31 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
) : isDiffFieldChanged && !isSummaryOnly ? (
<SquareDot className="size-3.5 shrink-0 text-sky-800 dark:text-sky-200" />
) : null}
{editMode && !readonly ? (
<>
<Input
ref={inputRef}
onBlur={editFieldName}
placeholder={field.name}
autoFocus
type="text"
value={fieldName}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setFieldName(e.target.value)}
className="h-5 w-full border-[0.5px] border-blue-400 bg-slate-100 focus-visible:ring-0 dark:bg-slate-900"
/>
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={editFieldName}
>
<Check className="size-4" />
</Button>
</>
) : (
<span
className={cn('truncate min-w-0', {
'text-red-800 font-normal dark:text-red-200':
isDiffFieldRemoved,
'text-green-800 font-normal dark:text-green-200':
isDiffNewField,
'text-sky-800 font-normal dark:text-sky-200':
isDiffFieldChanged &&
!isSummaryOnly &&
!isDiffFieldRemoved &&
!isDiffNewField,
})}
onDoubleClick={enterEditMode}
>
{fieldDiffChangedName ? (
<>
{field.name}{' '}
<span className="font-medium"></span>{' '}
{fieldDiffChangedName}
</>
) : (
field.name
)}
</span>
)}
{field.comments && !editMode ? (
<span
className={cn('truncate min-w-0', {
'text-red-800 font-normal dark:text-red-200':
isDiffFieldRemoved,
'text-green-800 font-normal dark:text-green-200':
isDiffNewField,
'text-sky-800 font-normal dark:text-sky-200':
isDiffFieldChanged &&
!isSummaryOnly &&
!isDiffFieldRemoved &&
!isDiffNewField,
})}
>
{fieldDiffChangedName ? (
<>
{fieldDiffChangedName.old}{' '}
<span className="font-medium"></span>{' '}
{fieldDiffChangedName.new}
</>
) : (
field.name
)}
</span>
{field.comments ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="shrink-0 cursor-pointer text-muted-foreground">
@@ -423,37 +391,13 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
</Tooltip>
) : null}
</div>
{editMode ? null : (
<div className="ml-2 flex shrink-0 items-center justify-end gap-1.5">
{(field.primaryKey &&
fieldDiffChangedPrimaryKey === null) ||
fieldDiffChangedPrimaryKey ? (
<div
className={cn(
'text-muted-foreground',
!readonly ? 'group-hover:hidden' : '',
isDiffFieldRemoved
? 'text-red-800 dark:text-red-200'
: '',
isDiffNewField
? 'text-green-800 dark:text-green-200'
: '',
isDiffFieldChanged &&
!isSummaryOnly &&
!isDiffFieldRemoved &&
!isDiffNewField
? 'text-sky-800 dark:text-sky-200'
: ''
)}
>
<KeyRound size={14} />
</div>
) : null}
<div className="ml-2 flex shrink-0 items-center justify-end gap-1.5">
{(field.primaryKey && !fieldDiffChangedPrimaryKey?.old) ||
fieldDiffChangedPrimaryKey?.new ? (
<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]',
'text-muted-foreground',
!readonly ? 'group-hover:hidden' : '',
isDiffFieldRemoved
? 'text-red-800 dark:text-red-200'
@@ -462,74 +406,100 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
? 'text-green-800 dark:text-green-200'
: '',
isDiffFieldChanged &&
!isDiffFieldRemoved &&
!isSummaryOnly &&
!isDiffFieldRemoved &&
!isDiffNewField
? 'text-sky-800 dark:text-sky-200'
: ''
)}
>
<span className="block truncate">
{fieldDiffChangedType ? (
<>
<span className="line-through">
{field.type.name.split(' ')[0]}
</span>{' '}
<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' : '',
isDiffFieldRemoved
? 'text-red-800 dark:text-red-200'
: '',
isDiffNewField
? 'text-green-800 dark:text-green-200'
: '',
isDiffFieldChanged &&
!isDiffFieldRemoved &&
!isSummaryOnly &&
!isDiffNewField
? 'text-sky-800 dark:text-sky-200'
: ''
)}
>
<span className="block truncate">
{fieldDiffChangedType ? (
<>
<span className="line-through">
{
fieldDiffChangedType.name.split(
fieldDiffChangedType.old.name.split(
' '
)[0]
}
</>
</span>{' '}
{
fieldDiffChangedType.new.name.split(
' '
)[0]
}
</>
) : (
`${field.type.name.split(' ')[0]}${
showFieldAttributes
? generateDBFieldSuffix({
...field,
...{
precision:
fieldDiffChangedPrecision?.new ??
field.precision,
scale:
fieldDiffChangedScale?.new ??
field.scale,
characterMaximumLength:
fieldDiffChangedCharacterMaximumLength?.new ??
field.characterMaximumLength,
},
})
: ''
}`
)}
{fieldDiffChangedNullable ? (
fieldDiffChangedNullable.new ? (
<span className="font-semibold">?</span>
) : (
`${field.type.name.split(' ')[0]}${
showFieldAttributes
? generateDBFieldSuffix({
...field,
...{
precision:
fieldDiffChangedPrecision ??
field.precision,
scale:
fieldDiffChangedScale ??
field.scale,
characterMaximumLength:
fieldDiffChangedCharacterMaximumLength ??
field.characterMaximumLength,
},
})
: ''
}`
)}
{fieldDiffChangedNullable !== null ? (
fieldDiffChangedNullable ? (
<span className="font-semibold">?</span>
) : (
<span className="line-through">?</span>
)
) : field.nullable ? (
'?'
) : (
''
)}
</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();
removeField(tableNodeId, field.id);
}}
>
<Trash2 className="size-3.5 text-red-700" />
</Button>
</div>
)}
<span className="line-through">?</span>
)
) : field.nullable ? (
'?'
) : (
''
)}
</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>
</div>
);
},

View File

@@ -6,7 +6,13 @@ import React, {
useEffect,
} from 'react';
import type { NodeProps, Node } from '@xyflow/react';
import { NodeResizer, useConnection, useStore } from '@xyflow/react';
import {
NodeResizer,
useConnection,
useStore,
Handle,
Position,
} from '@xyflow/react';
import { Button } from '@/components/button/button';
import {
ChevronsLeftRight,
@@ -14,7 +20,6 @@ import {
Table2,
ChevronDown,
ChevronUp,
Check,
CircleDotDashed,
SquareDot,
SquarePlus,
@@ -38,8 +43,6 @@ import { TableNodeContextMenu } from './table-node-context-menu';
import { cn } from '@/lib/utils';
import { TableNodeDependencyIndicator } from './table-node-dependency-indicator';
import type { EdgeType } from '../canvas';
import { Input } from '@/components/input/input';
import { useClickAway, useKeyPressEvent } from 'react-use';
import {
Tooltip,
TooltipContent,
@@ -47,6 +50,11 @@ import {
} from '@/components/tooltip/tooltip';
import { useDiff } from '@/context/diff-context/use-diff';
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<
{
@@ -55,6 +63,7 @@ export type TableNodeType = Node<
highlightOverlappingTables?: boolean;
hasHighlightedCustomType?: boolean;
highlightTable?: boolean;
isRelationshipCreatingTarget?: boolean;
},
'table'
>;
@@ -70,17 +79,40 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
highlightOverlappingTables,
hasHighlightedCustomType,
highlightTable,
isRelationshipCreatingTarget,
},
}) => {
const { updateTable, relationships, readonly } = useChartDB();
const edges = useStore((store) => store.edges) as EdgeType[];
const { openTableFromSidebar, selectSidebarSection } = useLayout();
const {
openTableFromSidebar,
selectSidebarSection,
closeAllTablesInSidebar,
} = useLayout();
const [expanded, setExpanded] = useState(table.expanded ?? false);
const { t } = useTranslation();
const [editMode, setEditMode] = useState(false);
const [tableName, setTableName] = useState(table.name);
const inputRef = React.useRef<HTMLInputElement>(null);
const [isHovering, setIsHovering] = useState(false);
const {
setEditTableModeTable,
editTableModeTable,
setHoveringTableId,
showCreateRelationshipNode,
tempFloatingEdge,
} = useCanvas();
// Get edit mode state directly from context
const editTableMode = useMemo(
() => editTableModeTable?.tableId === table.id,
[editTableModeTable, table.id]
);
const editTableModeFieldId = useMemo(
() => (editTableMode ? editTableModeTable?.fieldId : null),
[editTableMode, editTableModeTable]
);
// Store the initial field count when entering edit mode to keep table height fixed
const [editModeInitialFieldCount, setEditModeInitialFieldCount] =
useState<number | null>(null);
const connection = useConnection();
@@ -101,6 +133,17 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
const fields = useMemo(() => table.fields, [table.fields]);
// Effect to manage field count when entering/exiting edit mode
useEffect(() => {
if (editTableMode && editModeInitialFieldCount === null) {
// Entering edit mode - capture current field count
setEditModeInitialFieldCount(fields.length);
} else if (!editTableMode && editModeInitialFieldCount !== null) {
// Exiting edit mode - reset
setEditModeInitialFieldCount(null);
}
}, [editTableMode, fields.length, editModeInitialFieldCount]);
const tableChangedName = useMemo(
() => getTableNewName({ tableId: table.id }),
[getTableNewName, table.id]
@@ -112,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;
@@ -235,14 +278,20 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
}, [relationships]);
const visibleFields = useMemo(() => {
if (expanded || fields.length <= TABLE_MINIMIZED_FIELDS) {
return fields;
// If in edit mode, use the initial field count to keep consistent height
const fieldsToConsider =
editTableMode && editModeInitialFieldCount !== null
? fields.slice(0, editModeInitialFieldCount)
: fields;
if (expanded || fieldsToConsider.length <= TABLE_MINIMIZED_FIELDS) {
return fieldsToConsider;
}
const mustDisplayedFields: DBField[] = [];
const nonMustDisplayedFields: DBField[] = [];
for (const field of fields) {
for (const field of fieldsToConsider) {
if (relatedFieldIds.has(field.id) || field.primaryKey) {
mustDisplayedFields.push(field);
} else {
@@ -269,46 +318,33 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
...visibleMustDisplayedFields,
...visibleNonMustDisplayedFields,
]);
const result = fields.filter((field) =>
const result = fieldsToConsider.filter((field) =>
visibleFieldsSet.has(field)
);
return result;
}, [expanded, fields, relatedFieldIds]);
}, [
expanded,
fields,
relatedFieldIds,
editTableMode,
editModeInitialFieldCount,
]);
const editTableName = useCallback(() => {
if (!editMode) return;
if (tableName.trim()) {
updateTable(table.id, { name: tableName.trim() });
}
setEditMode(false);
}, [tableName, table.id, updateTable, editMode]);
const abortEdit = useCallback(() => {
setEditMode(false);
setTableName(table.name);
}, [table.name]);
useClickAway(inputRef, editTableName);
useKeyPressEvent('Enter', editTableName);
useKeyPressEvent('Escape', abortEdit);
const enterEditMode = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setEditMode(true);
}, []);
React.useEffect(() => {
if (table.name.trim()) {
setTableName(table.name.trim());
}
}, [table.name]);
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
@@ -337,7 +373,9 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
: '',
isDiffTableRemoved
? 'outline outline-[3px] outline-red-500 dark:outline-red-900 outline-offset-[5px]'
: ''
: editTableMode
? 'invisible'
: ''
),
[
selected,
@@ -350,20 +388,72 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
isDiffNewTable,
isDiffTableRemoved,
isTarget,
editTableMode,
isPartOfCreatingRelationship,
]
);
const enterEditTableMode = useCallback(() => {
if (readonly) {
return;
}
closeAllTablesInSidebar();
setEditTableModeTable({ tableId: table.id });
}, [
table.id,
setEditTableModeTable,
closeAllTablesInSidebar,
readonly,
]);
const exitEditTableMode = useCallback(() => {
setEditTableModeTable(null);
}, [setEditTableModeTable]);
return (
<TableNodeContextMenu table={table}>
{editTableMode ? (
<TableEditMode
table={table}
color={tableColor}
focusFieldId={editTableModeFieldId ?? undefined}
onClose={() => {
exitEditTableMode();
}}
/>
) : null}
<div
className={tableClassName}
onClick={(e) => {
if (e.detail === 2) {
openTableInEditor();
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}
@@ -373,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}
@@ -435,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 ? (
@@ -456,47 +565,14 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
{table.name}
</Label>
) : editMode && !readonly ? (
<>
<Input
ref={inputRef}
onBlur={editTableName}
placeholder={table.name}
autoFocus
type="text"
value={tableName}
onClick={(e) => e.stopPropagation()}
onChange={(e) =>
setTableName(e.target.value)
}
className="h-6 w-full border-[0.5px] border-blue-400 bg-slate-100 focus-visible:ring-0 dark:bg-slate-900"
/>
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={editTableName}
>
<Check className="size-4" />
</Button>
</>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Label
className="text-editable truncate px-2 py-0.5 text-sm font-bold"
onDoubleClick={enterEditMode}
>
{table.name}
</Label>
</TooltipTrigger>
<TooltipContent>
{t('tool_tips.double_click_to_edit')}
</TooltipContent>
</Tooltip>
<Label className="truncate px-2 py-0.5 text-sm font-bold">
{table.name}
</Label>
)}
</div>
<div className="hidden shrink-0 flex-row group-hover:flex">
{readonly || editMode ? null : (
{readonly ? null : (
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
@@ -505,30 +581,28 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
<CircleDotDashed className="size-4" />
</Button>
)}
{editMode ? null : (
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={
table.width !== MAX_TABLE_SIZE
? expandTable
: shrinkTable
}
>
{table.width !== MAX_TABLE_SIZE ? (
<ChevronsLeftRight className="size-4" />
) : (
<ChevronsRightLeft className="size-4" />
)}
</Button>
)}
<Button
variant="ghost"
className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
onClick={
table.width !== MAX_TABLE_SIZE
? expandTable
: shrinkTable
}
>
{table.width !== MAX_TABLE_SIZE ? (
<ChevronsLeftRight className="size-4" />
) : (
<ChevronsRightLeft className="size-4" />
)}
</Button>
</div>
</div>
<div
className="transition-[max-height] duration-200 ease-in-out"
style={{
maxHeight: expanded
? `${fields.length * 2}rem` // h-8 per field
? `${(editTableMode && editModeInitialFieldCount !== null ? editModeInitialFieldCount : fields.length) * 2}rem` // h-8 per field
: `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field
}}
>
@@ -544,7 +618,9 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
/>
))}
</div>
{fields.length > TABLE_MINIMIZED_FIELDS && (
{(editTableMode && editModeInitialFieldCount !== null
? editModeInitialFieldCount
: fields.length) > TABLE_MINIMIZED_FIELDS && (
<div
className="z-10 flex h-8 cursor-pointer items-center justify-center rounded-b-md border-t text-xs text-muted-foreground transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-800"
onClick={toggleExpand}

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import React, {
useCallback,
useRef,
} from 'react';
import { useDebounceFn } from 'ahooks';
import { useChartDB } from '@/hooks/use-chartdb';
import { useTheme } from '@/hooks/use-theme';
import { CodeSnippet } from '@/components/code-snippet/code-snippet';
@@ -25,7 +26,6 @@ import { generateDBMLFromDiagram } from '@/lib/dbml/dbml-export/dbml-export';
import { useDiff } from '@/context/diff-context/use-diff';
import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import';
import { applyDBMLChanges } from '@/lib/dbml/apply-dbml/apply-dbml';
import { useDebounce } from '@/hooks/use-debounce';
import { parseDBMLError } from '@/lib/dbml/dbml-import/dbml-import-error';
import {
clearErrorHighlight,
@@ -113,6 +113,17 @@ export const TableDBML: React.FC<TableDBMLProps> = () => {
const { hideLoader, showLoader } = useFullScreenLoader();
const emphasisTimeoutRef = useRef<NodeJS.Timeout>();
const readOnlyDisposableRef = useRef<monaco.IDisposable>();
const currentDiagramRef = useRef<Diagram>(currentDiagram);
const originalDiagramRef = useRef<Diagram | null>(originalDiagram);
// Keep refs updated
useEffect(() => {
currentDiagramRef.current = currentDiagram;
}, [currentDiagram]);
useEffect(() => {
originalDiagramRef.current = originalDiagram;
}, [originalDiagram]);
// --- Check for empty field name warnings only on mount ---
useEffect(() => {
@@ -190,7 +201,7 @@ export const TableDBML: React.FC<TableDBMLProps> = () => {
);
const sourceDiagram: Diagram =
originalDiagram ?? currentDiagram;
originalDiagramRef.current ?? currentDiagramRef.current;
const targetDiagram: Diagram = {
...sourceDiagram,
@@ -204,9 +215,9 @@ export const TableDBML: React.FC<TableDBMLProps> = () => {
targetDiagram,
});
if (originalDiagram) {
if (originalDiagramRef.current) {
resetDiff();
loadDiagramFromData(originalDiagram);
loadDiagramFromData(originalDiagramRef.current);
}
calculateDiff({
@@ -232,18 +243,12 @@ export const TableDBML: React.FC<TableDBMLProps> = () => {
}
}
},
[
t,
originalDiagram,
currentDiagram,
resetDiff,
loadDiagramFromData,
calculateDiff,
databaseType,
]
[t, resetDiff, loadDiagramFromData, calculateDiff, databaseType]
);
const debouncedShowDiff = useDebounce(showDiff, 1000);
const { run: debouncedShowDiff } = useDebounceFn(showDiff, {
wait: 1000,
});
useEffect(() => {
if (!isEditMode || !editedDbml) {
@@ -359,8 +364,10 @@ export const TableDBML: React.FC<TableDBMLProps> = () => {
{
label: 'View',
icon: PencilOff,
onClick: () =>
setIsEditMode((prev) => !prev),
onClick: () => {
resetDiff();
setIsEditMode((prev) => !prev);
},
},
]
: [

View File

@@ -173,9 +173,11 @@ export const RelationshipListItemContent: React.FC<
<SelectItem value="many_to_one">
{t('relationship_type.many_to_one')}
</SelectItem>
<SelectItem value="many_to_many">
{t('relationship_type.many_to_many')}
</SelectItem>
{relationshipType === 'many_to_many' ? (
<SelectItem value="many_to_many">
{t('relationship_type.many_to_many')}
</SelectItem>
) : null}
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -1,13 +1,9 @@
import React, { useCallback, useMemo } from 'react';
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 type { DataTypeData } from '@/lib/data/data-types/data-types';
import {
dataTypeDataToDataType,
sortedDataTypeMap,
} from '@/lib/data/data-types/data-types';
import { useUpdateTableField } from '@/hooks/use-update-table-field';
import {
Tooltip,
TooltipContent,
@@ -17,10 +13,6 @@ import { useTranslation } from 'react-i18next';
import { TableFieldToggle } from './table-field-toggle';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type {
SelectBoxOption,
SelectBoxProps,
} from '@/components/select-box/select-box';
import { SelectBox } from '@/components/select-box/select-box';
import { TableFieldPopover } from './table-field-modal/table-field-modal';
import type { DBTable } from '@/lib/domain';
@@ -32,213 +24,35 @@ export interface TableFieldProps {
removeField: () => void;
}
const generateFieldRegexPatterns = (
dataType: DataTypeData
): {
regex?: string;
extractRegex?: RegExp;
} => {
if (!dataType.fieldAttributes) {
return { regex: undefined, extractRegex: undefined };
}
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,
};
}
return {
regex: `^${typeName}\\(\\d+\\)$`,
extractRegex: /\((\d+)\)/,
};
}
if (fieldAttributes.precision && fieldAttributes.scale) {
return {
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)$`,
extractRegex: new RegExp(
`${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)`
),
};
}
if (fieldAttributes.precision) {
return {
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)$`,
extractRegex: /\((\d+)\)/,
};
}
return { regex: undefined, extractRegex: undefined };
};
export const TableField: React.FC<TableFieldProps> = ({
table,
field,
updateField,
removeField,
}) => {
const { databaseType, customTypes, readonly } = useChartDB();
const { databaseType, readonly } = useChartDB();
const { t } = useTranslation();
// Only calculate primary key fields, not just count
const primaryKeyFields = useMemo(() => {
return table.fields.filter((f) => f.primaryKey);
}, [table.fields]);
const primaryKeyCount = primaryKeyFields.length;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: field.id });
const dataFieldOptions = useMemo(() => {
const standardTypes: SelectBoxOption[] = sortedDataTypeMap[
databaseType
].map((type) => {
const regexPatterns = generateFieldRegexPatterns(type);
return {
label: type.name,
value: type.id,
regex: regexPatterns.regex,
extractRegex: regexPatterns.extractRegex,
group: customTypes?.length ? 'Standard Types' : undefined,
};
});
if (!customTypes?.length) {
return standardTypes;
}
// Add custom types as options
const customTypeOptions: SelectBoxOption[] = customTypes.map(
(type) => ({
label: type.name,
value: type.name,
description:
type.kind === 'enum' ? `${type.values?.join(' | ')}` : '',
group: 'Custom Types',
})
);
return [...standardTypes, ...customTypeOptions];
}, [databaseType, customTypes]);
const onChangeDataType = useCallback<
NonNullable<SelectBoxProps['onChange']>
>(
(value, regexMatches) => {
const dataType = sortedDataTypeMap[databaseType].find(
(v) => v.id === value
) ?? {
id: value as string,
name: value as string,
};
let characterMaximumLength: string | undefined = undefined;
let precision: number | undefined = undefined;
let scale: number | undefined = undefined;
if (regexMatches?.length) {
if (dataType?.fieldAttributes?.hasCharMaxLength) {
characterMaximumLength = regexMatches[1]?.toLowerCase();
} else if (
dataType?.fieldAttributes?.precision &&
dataType?.fieldAttributes?.scale
) {
precision = parseInt(regexMatches[1]);
scale = regexMatches[2]
? parseInt(regexMatches[2])
: undefined;
} else if (dataType?.fieldAttributes?.precision) {
precision = parseInt(regexMatches[1]);
}
} else {
if (
dataType?.fieldAttributes?.hasCharMaxLength &&
field.characterMaximumLength
) {
characterMaximumLength = field.characterMaximumLength;
}
if (dataType?.fieldAttributes?.precision && field.precision) {
precision = field.precision;
}
if (dataType?.fieldAttributes?.scale && field.scale) {
scale = field.scale;
}
}
updateField({
characterMaximumLength,
precision,
scale,
increment: undefined,
default: undefined,
type: dataTypeDataToDataType(
dataType ?? {
id: value as string,
name: value as string,
}
),
});
},
[
updateField,
databaseType,
field.characterMaximumLength,
field.precision,
field.scale,
]
);
const {
dataFieldOptions,
handleDataTypeChange,
handlePrimaryKeyToggle,
handleNullableToggle,
handleNameChange,
generateFieldSuffix,
fieldName,
nullable,
primaryKey,
} = useUpdateTableField(table, field, updateField);
const style = {
transform: CSS.Translate.toString(transform),
transition,
};
const handlePrimaryKeyToggle = useCallback(
(value: boolean) => {
if (value) {
// When setting as primary key
const updates: Partial<DBField> = {
primaryKey: true,
};
// Only auto-set unique if this will be the only primary key
if (primaryKeyCount === 0) {
updates.unique = true;
}
updateField(updates);
} else {
// When removing primary key
updateField({
primaryKey: false,
});
}
},
[primaryKeyCount, updateField]
);
const handleNullableToggle = useCallback(
(value: boolean) => {
updateField({ nullable: value });
},
[updateField]
);
const handleNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateField({ name: e.target.value });
},
[updateField]
);
return (
<div
className="flex flex-1 touch-none flex-row justify-between gap-2 p-1"
@@ -264,8 +78,10 @@ export const TableField: React.FC<TableFieldProps> = ({
placeholder={t(
'side_panel.tables_section.table.field_name'
)}
value={field.name}
onChange={handleNameChange}
value={fieldName}
onChange={(e) =>
handleNameChange(e.target.value)
}
readOnly={readonly}
/>
</span>
@@ -285,13 +101,9 @@ export const TableField: React.FC<TableFieldProps> = ({
value={field.type.id}
valueSuffix={generateDBFieldSuffix(field)}
optionSuffix={(option) =>
generateDBFieldSuffix(field, {
databaseType,
forceExtended: true,
typeId: option.value,
})
generateFieldSuffix(option.value)
}
onChange={onChangeDataType}
onChange={handleDataTypeChange}
emptyPlaceholder={t(
'side_panel.tables_section.table.no_types_found'
)}
@@ -312,7 +124,7 @@ export const TableField: React.FC<TableFieldProps> = ({
<TooltipTrigger asChild>
<span>
<TableFieldToggle
pressed={field.nullable}
pressed={nullable}
onPressedChange={handleNullableToggle}
disabled={readonly}
>
@@ -328,7 +140,7 @@ export const TableField: React.FC<TableFieldProps> = ({
<TooltipTrigger asChild>
<span>
<TableFieldToggle
pressed={field.primaryKey}
pressed={primaryKey}
onPressedChange={handlePrimaryKeyToggle}
disabled={readonly}
>

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/button/button';
import { Check } from 'lucide-react';
import { Check, Pencil } from 'lucide-react';
import { Input } from '@/components/input/input';
import { useChartDB } from '@/hooks/use-chartdb';
import { useClickAway, useKeyPressEvent } from 'react-use';
@@ -32,22 +32,39 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
}, [diagramName]);
const editDiagramName = useCallback(() => {
if (!editMode) return;
if (editedDiagramName.trim()) {
updateDiagramName(editedDiagramName.trim());
}
setEditMode(false);
}, [editedDiagramName, updateDiagramName, editMode]);
}, [editedDiagramName, updateDiagramName]);
// Handle click outside to save and exit edit mode
useClickAway(inputRef, editDiagramName);
useKeyPressEvent('Enter', editDiagramName);
const enterEditMode = (
event: React.MouseEvent<HTMLHeadingElement, MouseEvent>
) => {
event.stopPropagation();
setEditMode(true);
};
useEffect(() => {
if (editMode) {
// Small delay to ensure the input is rendered
const timeoutId = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, 50); // Slightly longer delay to ensure DOM is ready
return () => clearTimeout(timeoutId);
}
}, [editMode]);
const enterEditMode = useCallback(
(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
event.stopPropagation();
setEditedDiagramName(diagramName);
setEditMode(true);
},
[diagramName]
);
return (
<div className="group">
@@ -80,11 +97,16 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
onChange={(e) =>
setEditedDiagramName(e.target.value)
}
className="ml-1 h-7 focus-visible:ring-0"
className="h-7 max-w-[300px] focus-visible:ring-0"
style={{
width: `${
editedDiagramName.length * 8 + 30
}px`,
}}
/>
<Button
variant="ghost"
className="flex size-7 p-2 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
className="ml-1 flex size-7 p-2 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
onClick={editDiagramName}
>
<Check />
@@ -97,7 +119,7 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
<h1
className={cn(
labelVariants(),
'group-hover:underline'
'group-hover:underline max-w-[300px] truncate'
)}
onDoubleClick={(e) => {
enterEditMode(e);
@@ -110,6 +132,16 @@ export const DiagramName: React.FC<DiagramNameProps> = () => {
{t('tool_tips.double_click_to_edit')}
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
className="ml-1 hidden size-5 p-0 hover:bg-background/50 group-hover:flex"
onClick={enterEditMode}
>
<Pencil
strokeWidth="1.5"
className="!size-3.5 text-slate-600 dark:text-slate-400"
/>
</Button>
</>
)}
</div>

View File

@@ -46,7 +46,6 @@ export const Menu: React.FC<MenuProps> = () => {
openExportImageDialog,
openExportDiagramDialog,
openImportDiagramDialog,
openImportDBMLDialog,
} = useDialog();
const { showAlert } = useAlert();
const { setTheme, theme } = useTheme();
@@ -185,9 +184,6 @@ export const Menu: React.FC<MenuProps> = () => {
<MenubarItem onClick={openImportDiagramDialog}>
.json
</MenubarItem>
<MenubarItem onClick={() => openImportDBMLDialog()}>
.dbml
</MenubarItem>
<MenubarSeparator />
<MenubarItem
onClick={() =>