Compare commits

..

23 Commits

Author SHA1 Message Date
johnnyfish
7382626b92 fix: maintain field input focus during editing in table edit mode 2025-09-11 12:49:51 +03:00
johnnyfish
6f6b59c74f feat: upgrade table edit mode with SelectBox component and varchar(100) defaults 2025-09-11 12:49:51 +03:00
johnnyfish
4f1a378762 feat: add inline table edit mode with field management on canvas 2025-09-11 12:49:51 +03:00
Guy Ben-Aharon
1a6688e85e alignment (#912) 2025-09-11 12:32:58 +03:00
Guy Ben-Aharon
5e81c1848a fix(dbml): export array fields without quotes (#911) 2025-09-10 22:24:05 +03:00
Guy Ben-Aharon
2bd9ca25b2 fix: update deps vulns (#909) 2025-09-10 16:37:33 +03:00
Guy Ben-Aharon
b016a70691 fix: move auto arrange to toolbar (#904) 2025-09-07 12:02:33 +03:00
Jonathan Fishner
a0fb1ed08b feat: add zoom navigation buttons to canvas filter for tables and areas (#903)
* feat: add zoom navigation buttons to canvas filter for tables and areas

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-09-04 16:18:50 +03:00
Guy Ben-Aharon
ffddcdcc98 fix: export sql + import metadata lib (#902) 2025-09-04 12:10:56 +03:00
Jonathan Fishner
fe9ef275b8 fix: improve SQL default value parsing for PostgreSQL, MySQL, and SQL Server with proper type handling and casting support (#900) 2025-09-04 11:18:02 +03:00
Guy Ben-Aharon
df89f0b6b9 fix: remove general db creation (#901) 2025-09-03 20:57:12 +03:00
Guy Ben-Aharon
534d2858af readonly editor (#899) 2025-09-03 15:59:21 +03:00
Jonathan Fishner
2a64deebb8 fix(sql-import): handle SQL Server DDL with multiple tables, inline foreign keys, and case-insensitive field matching (#897) 2025-09-02 15:15:15 +03:00
Guy Ben-Aharon
e5e1d59327 fix: reset increment and default when change field (#896) 2025-09-01 18:48:00 +03:00
Guy Ben-Aharon
aa290615ca fix(sql-import): support ALTER TABLE ALTER COLUMN TYPE in PostgreSQL importer (#895) 2025-09-01 17:13:42 +03:00
Guy Ben-Aharon
ec6e46fe81 fix: add support for ALTER TABLE ADD COLUMN in PostgreSQL importer (#892) 2025-09-01 11:45:14 +03:00
Guy Ben-Aharon
ac128d67de align filter (#890) 2025-08-31 19:18:43 +03:00
Guy Ben-Aharon
07937a2f51 fix: export dbml issues after upgrade version (#883)
* fix: dbml export

* fix

* fix

* fix

* fix

* fix
2025-08-27 20:44:18 +03:00
Guy Ben-Aharon
d8e0bc7db8 fix: upgrade dbml lib (#880) 2025-08-27 14:42:02 +03:00
Guy Ben-Aharon
1ce265781b chore(main): release 1.15.1 (#878) 2025-08-27 12:53:00 +03:00
Aaron Dewes
60c5675cbf fix(custom-types): Make schema optional (#866)
* fix(custom-types): Make schema optional

The schema is optional in practice for custom types (as seen in the TS types above), and not always included in exports.

* add nullable

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-08-27 12:48:14 +03:00
Jonathan Fishner
66b086378c fix: handle quoted identifiers with special characters in SQL import/export and DBML generation (#877)
* fix: handle quoted identifiers with special characters in SQL import/export and DBML generation

* add tests and fix build

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-08-27 12:21:41 +03:00
Guy Ben-Aharon
abd2a6ccbe fix: add actions menu to diagram list + add duplicate diagram (#876) 2025-08-26 17:10:25 +03:00
122 changed files with 89355 additions and 2241 deletions

2
.nvmrc
View File

@@ -1 +1 @@
v22.5.1 v22.18.0

View File

@@ -1,5 +1,14 @@
# Changelog # Changelog
## [1.15.1](https://github.com/chartdb/chartdb/compare/v1.15.0...v1.15.1) (2025-08-27)
### Bug Fixes
* add actions menu to diagram list + add duplicate diagram ([#876](https://github.com/chartdb/chartdb/issues/876)) ([abd2a6c](https://github.com/chartdb/chartdb/commit/abd2a6ccbe1aa63db44ec28b3eff525cc5d3f8b0))
* **custom-types:** Make schema optional ([#866](https://github.com/chartdb/chartdb/issues/866)) ([60c5675](https://github.com/chartdb/chartdb/commit/60c5675cbfe205859d2d0c9848d8345a0a854671))
* handle quoted identifiers with special characters in SQL import/export and DBML generation ([#877](https://github.com/chartdb/chartdb/issues/877)) ([66b0863](https://github.com/chartdb/chartdb/commit/66b086378cd63347acab5fc7f13db7db4feaa872))
## [1.15.0](https://github.com/chartdb/chartdb/compare/v1.14.0...v1.15.0) (2025-08-26) ## [1.15.0](https://github.com/chartdb/chartdb/compare/v1.14.0...v1.15.0) (2025-08-26)

281
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{ {
"name": "chartdb", "name": "chartdb",
"version": "1.15.0", "version": "1.15.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "chartdb", "name": "chartdb",
"version": "1.15.0", "version": "1.15.1",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^0.0.51", "@ai-sdk/openai": "^0.0.51",
"@dbml/core": "^3.9.5", "@dbml/core": "^3.13.9",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-accordion": "^1.2.0",
@@ -586,15 +586,15 @@
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.26.2", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-validator-identifier": "^7.25.9", "@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0", "js-tokens": "^4.0.0",
"picocolors": "^1.0.0" "picocolors": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -738,18 +738,18 @@
} }
}, },
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.25.9", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -766,26 +766,26 @@
} }
}, },
"node_modules/@babel/helpers": { "node_modules/@babel/helpers": {
"version": "7.26.7", "version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
"integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.25.9", "@babel/template": "^7.27.2",
"@babel/types": "^7.26.7" "@babel/types": "^7.28.4"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.26.7", "version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.26.7" "@babel/types": "^7.28.4"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
@@ -827,27 +827,24 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.26.7", "version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT", "license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.25.9", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.25.9", "@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.25.9", "@babel/parser": "^7.27.2",
"@babel/types": "^7.25.9" "@babel/types": "^7.27.1"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -883,25 +880,25 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.26.7", "version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
"integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.25.9", "@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.25.9" "@babel/helper-validator-identifier": "^7.27.1"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dbml/core": { "node_modules/@dbml/core": {
"version": "3.9.5", "version": "3.13.9",
"resolved": "https://registry.npmjs.org/@dbml/core/-/core-3.9.5.tgz", "resolved": "https://registry.npmjs.org/@dbml/core/-/core-3.13.9.tgz",
"integrity": "sha512-lX/G5qer42irufv5rvx6Y3ISV2ZLDRlxj8R+OZMdhC6wAw0VYPYIts23MdMFPY39Iay0TDtfmwsbOsVy/yjSIg==", "integrity": "sha512-JgJ470yuTZU7tP64ZL5FpEh7zSXjSoKzkARmin8iVVhdsNM8Nq4e+FFhG6J6acPtGHtoLahOs9LqrC17B9MqYg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@dbml/parse": "^3.9.5", "@dbml/parse": "^3.13.9",
"antlr4": "^4.13.1", "antlr4": "^4.13.1",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"parsimmon": "^1.13.0", "parsimmon": "^1.13.0",
@@ -912,15 +909,15 @@
} }
}, },
"node_modules/@dbml/parse": { "node_modules/@dbml/parse": {
"version": "3.9.5", "version": "3.13.9",
"resolved": "https://registry.npmjs.org/@dbml/parse/-/parse-3.9.5.tgz", "resolved": "https://registry.npmjs.org/@dbml/parse/-/parse-3.13.9.tgz",
"integrity": "sha512-z8MjBYDFiYf7WtsagwGATEye81xQcO9VXFzttSjdJ+wgdSFzFSex9letJPIMIcYXBkm4Fg5qLDk+G9uq/413Dg==", "integrity": "sha512-JMfOxWquXMZpF/MTLy2xWLImx3z9D0t67T7x/BT892WvmhM+9cnJHFA2URT1NXu9jdajbTTFuoWSyzdsfNpaRw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"lodash": "^4.17.21" "lodash-es": "^4.17.21"
}, },
"peerDependencies": { "engines": {
"lodash": "^4.17.21" "node": ">=18"
} }
}, },
"node_modules/@dnd-kit/accessibility": { "node_modules/@dnd-kit/accessibility": {
@@ -1370,9 +1367,9 @@
} }
}, },
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.4.1", "version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
"integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1417,9 +1414,9 @@
} }
}, },
"node_modules/@eslint/config-array": { "node_modules/@eslint/config-array": {
"version": "0.19.2", "version": "0.21.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
"integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -1431,10 +1428,20 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@eslint/config-helpers": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz",
"integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": { "node_modules/@eslint/core": {
"version": "0.10.0", "version": "0.15.2",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
"integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -1445,9 +1452,9 @@
} }
}, },
"node_modules/@eslint/eslintrc": { "node_modules/@eslint/eslintrc": {
"version": "3.2.0", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
"integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1482,13 +1489,16 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.19.0", "version": "9.35.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz",
"integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==", "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
} }
}, },
"node_modules/@eslint/object-schema": { "node_modules/@eslint/object-schema": {
@@ -1502,13 +1512,13 @@
} }
}, },
"node_modules/@eslint/plugin-kit": { "node_modules/@eslint/plugin-kit": {
"version": "0.2.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
"integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.10.0", "@eslint/core": "^0.15.2",
"levn": "^0.4.1" "levn": "^0.4.1"
}, },
"engines": { "engines": {
@@ -1606,9 +1616,9 @@
} }
}, },
"node_modules/@humanwhocodes/retry": { "node_modules/@humanwhocodes/retry": {
"version": "0.4.1", "version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@@ -4276,12 +4286,6 @@
"@types/deep-eql": "*" "@types/deep-eql": "*"
} }
}, },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/d3-color": { "node_modules/@types/d3-color": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
@@ -4553,9 +4557,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4961,9 +4965,9 @@
} }
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
@@ -5486,9 +5490,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -6578,22 +6582,23 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.19.0", "version": "9.35.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz",
"integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==", "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.19.0", "@eslint/config-array": "^0.21.0",
"@eslint/core": "^0.10.0", "@eslint/config-helpers": "^0.3.1",
"@eslint/eslintrc": "^3.2.0", "@eslint/core": "^0.15.2",
"@eslint/js": "9.19.0", "@eslint/eslintrc": "^3.3.1",
"@eslint/plugin-kit": "^0.2.5", "@eslint/js": "9.35.0",
"@eslint/plugin-kit": "^0.3.5",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.1", "@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6", "@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15", "@types/json-schema": "^7.0.15",
"ajv": "^6.12.4", "ajv": "^6.12.4",
@@ -6601,9 +6606,9 @@
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"debug": "^4.3.2", "debug": "^4.3.2",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.2.0", "eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.0", "eslint-visitor-keys": "^4.2.1",
"espree": "^10.3.0", "espree": "^10.4.0",
"esquery": "^1.5.0", "esquery": "^1.5.0",
"esutils": "^2.0.2", "esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
@@ -6812,9 +6817,9 @@
} }
}, },
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
"version": "8.2.0", "version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
@@ -6842,9 +6847,9 @@
} }
}, },
"node_modules/eslint/node_modules/eslint-visitor-keys": { "node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.0", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@@ -6862,15 +6867,15 @@
"peer": true "peer": true
}, },
"node_modules/espree": { "node_modules/espree": {
"version": "10.3.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"acorn": "^8.14.0", "acorn": "^8.15.0",
"acorn-jsx": "^5.3.2", "acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.0" "eslint-visitor-keys": "^4.2.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6880,9 +6885,9 @@
} }
}, },
"node_modules/espree/node_modules/eslint-visitor-keys": { "node_modules/espree/node_modules/eslint-visitor-keys": {
"version": "4.2.0", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@@ -7365,9 +7370,9 @@
} }
}, },
"node_modules/glob/node_modules/brace-expansion": { "node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -8440,6 +8445,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -9600,15 +9611,13 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.1.5", "version": "7.8.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.5.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz",
"integrity": "sha512-8BUF+hZEU4/z/JD201yK6S+UYhsf58bzYIDq2NS1iGpwxSXDu7F+DeGSkIXMFBuHZB21FSiCzEcUb18cQNdRkA==", "integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^1.0.1", "cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0", "set-cookie-parser": "^2.6.0"
"turbo-stream": "2.4.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -9624,12 +9633,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.1.5", "version": "7.8.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.1.5.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz",
"integrity": "sha512-/4f9+up0Qv92D3bB8iN5P1s3oHAepSGa9h5k6tpTFlixTTskJZwKGhJ6vRJ277tLD1zuaZTt95hyGWV1Z37csQ==", "integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.1.5" "react-router": "7.8.2"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -9760,12 +9769,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -11026,12 +11029,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/turbo-stream": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
"license": "ISC"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -11313,9 +11310,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.14", "version": "5.4.20",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz",
"integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -1,7 +1,7 @@
{ {
"name": "chartdb", "name": "chartdb",
"private": true, "private": true,
"version": "1.15.0", "version": "1.15.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -17,7 +17,7 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^0.0.51", "@ai-sdk/openai": "^0.0.51",
"@dbml/core": "^3.9.5", "@dbml/core": "^3.13.9",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-accordion": "^1.2.0",

View File

@@ -11,18 +11,26 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/dropdown-menu/dropdown-menu'; } from '@/components/dropdown-menu/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip/tooltip';
export interface ButtonAlternative {
label: string;
onClick: () => void;
disabled?: boolean;
icon?: React.ReactNode;
className?: string;
tooltip?: string;
}
export interface ButtonWithAlternativesProps export interface ButtonWithAlternativesProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
alternatives: Array<{ alternatives: Array<ButtonAlternative>;
label: string;
onClick: () => void;
disabled?: boolean;
icon?: React.ReactNode;
className?: string;
}>;
dropdownTriggerClassName?: string; dropdownTriggerClassName?: string;
chevronDownIconClassName?: string; chevronDownIconClassName?: string;
} }
@@ -87,19 +95,36 @@ const ButtonWithAlternatives = React.forwardRef<
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{alternatives.map((alternative, index) => ( {alternatives.map((alternative, index) => {
<DropdownMenuItem const menuItem = (
key={index} <DropdownMenuItem
onClick={alternative.onClick} key={index}
disabled={alternative.disabled} onClick={alternative.onClick}
className={cn(alternative.className)} disabled={alternative.disabled}
> className={cn(alternative.className)}
<span className="flex w-full items-center justify-between gap-2"> >
{alternative.label} <span className="flex w-full items-center justify-between gap-2">
{alternative.icon} {alternative.label}
</span> {alternative.icon}
</DropdownMenuItem> </span>
))} </DropdownMenuItem>
);
if (alternative.tooltip) {
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
{menuItem}
</TooltipTrigger>
<TooltipContent side="left">
{alternative.tooltip}
</TooltipContent>
</Tooltip>
);
}
return menuItem;
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null} ) : null}

View File

@@ -5,21 +5,33 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@/components/popover/popover'; } from '@/components/popover/popover';
import { colorOptions } from '@/lib/colors'; import { colorOptions } from '@/lib/colors';
import { cn } from '@/lib/utils';
export interface ColorPickerProps { export interface ColorPickerProps {
color: string; color: string;
onChange: (color: string) => void; onChange: (color: string) => void;
disabled?: boolean;
} }
export const ColorPicker = React.forwardRef< export const ColorPicker = React.forwardRef<
React.ElementRef<typeof PopoverTrigger>, React.ElementRef<typeof PopoverTrigger>,
ColorPickerProps ColorPickerProps
>(({ color, onChange }, ref) => { >(({ color, onChange, disabled }, ref) => {
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild ref={ref}> <PopoverTrigger
asChild
ref={ref}
disabled={disabled}
{...(disabled ? { onClick: (e) => e.preventDefault() } : {})}
>
<div <div
className="h-6 w-8 cursor-pointer rounded-md border-2 border-muted transition-shadow hover:shadow-md" className={cn(
'h-6 w-8 cursor-pointer rounded-md border-2 border-muted transition-shadow hover:shadow-md',
{
'hover:shadow-none cursor-default': disabled,
}
)}
style={{ style={{
backgroundColor: color, backgroundColor: color,
}} }}

View File

@@ -27,6 +27,7 @@ export interface SelectBoxOption {
regex?: string; regex?: string;
extractRegex?: RegExp; extractRegex?: RegExp;
group?: string; group?: string;
icon?: React.ReactNode;
} }
export interface SelectBoxProps { export interface SelectBoxProps {
@@ -53,6 +54,8 @@ export interface SelectBoxProps {
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
popoverClassName?: string; popoverClassName?: string;
readonly?: boolean;
footerButtons?: React.ReactNode;
} }
export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
@@ -78,6 +81,8 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
open, open,
onOpenChange: setOpen, onOpenChange: setOpen,
popoverClassName, popoverClassName,
readonly,
footerButtons,
}, },
ref ref
) => { ) => {
@@ -152,18 +157,20 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
className={`inline-flex min-w-0 shrink-0 items-center gap-1 rounded-md border py-0.5 pl-2 pr-1 text-xs font-medium text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${oneLine ? 'mx-0.5' : ''}`} className={`inline-flex min-w-0 shrink-0 items-center gap-1 rounded-md border py-0.5 pl-2 pr-1 text-xs font-medium text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${oneLine ? 'mx-0.5' : ''}`}
> >
<span>{option.label}</span> <span>{option.label}</span>
<span {!readonly ? (
onClick={(e) => { <span
e.preventDefault(); onClick={(e) => {
handleSelect(option.value); e.preventDefault();
}} handleSelect(option.value);
className="flex items-center rounded-sm px-px text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground" }}
> className="flex items-center rounded-sm px-px text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground"
<Cross2Icon /> >
</span> <Cross2Icon />
</span>
) : null}
</span> </span>
)), )),
[options, value, handleSelect, oneLine, keepOrder] [options, value, handleSelect, oneLine, keepOrder, readonly]
); );
const isAllSelected = React.useMemo( const isAllSelected = React.useMemo(
@@ -250,6 +257,11 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
</div> </div>
)} )}
<div className="flex flex-1 items-center truncate"> <div className="flex flex-1 items-center truncate">
{option.icon ? (
<span className="mr-2 shrink-0">
{option.icon}
</span>
) : null}
<span> <span>
{isRegexMatch ? searchTerm : option.label} {isRegexMatch ? searchTerm : option.label}
{!isRegexMatch && optionSuffix {!isRegexMatch && optionSuffix
@@ -284,7 +296,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
<PopoverTrigger asChild tabIndex={0} onKeyDown={handleKeyDown}> <PopoverTrigger asChild tabIndex={0} onKeyDown={handleKeyDown}>
<div <div
className={cn( className={cn(
`flex min-h-[36px] cursor-pointer items-center justify-between rounded-md border px-3 py-1 data-[state=open]:border-ring ${disabled ? 'bg-muted pointer-events-none' : ''}`, `flex min-h-[36px] cursor-pointer items-center justify-between rounded-md border px-3 py-1 data-[state=open]:border-ring ${disabled ? 'bg-muted pointer-events-none' : ''} ${readonly ? 'pointer-events-none' : ''}`,
className className
)} )}
> >
@@ -443,6 +455,9 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
</div> </div>
</ScrollArea> </ScrollArea>
</Command> </Command>
{footerButtons ? (
<div className="border-t">{footerButtons}</div>
) : null}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );

View File

@@ -41,8 +41,7 @@ export const ChartDBProvider: React.FC<
React.PropsWithChildren<ChartDBProviderProps> React.PropsWithChildren<ChartDBProviderProps>
> = ({ children, diagram, readonly: readonlyProp }) => { > = ({ children, diagram, readonly: readonlyProp }) => {
const { hasDiff } = useDiff(); const { hasDiff } = useDiff();
const dbStorage = useStorage(); const storageDB = useStorage();
let db = dbStorage;
const events = useEventEmitter<ChartDBEvent>(); const events = useEventEmitter<ChartDBEvent>();
const { addUndoAction, resetRedoStack, resetUndoStack } = const { addUndoAction, resetRedoStack, resetUndoStack } =
useRedoUndoStack(); useRedoUndoStack();
@@ -102,10 +101,6 @@ export const ChartDBProvider: React.FC<
[readonlyProp, hasDiff] [readonlyProp, hasDiff]
); );
if (readonly) {
db = storageInitialValue;
}
const schemas = useMemo( const schemas = useMemo(
() => () =>
databasesWithSchemas.includes(databaseType) databasesWithSchemas.includes(databaseType)
@@ -134,6 +129,11 @@ export const ChartDBProvider: React.FC<
[tables, defaultSchemaName, databaseType] [tables, defaultSchemaName, databaseType]
); );
const db = useMemo(
() => (readonly ? storageInitialValue : storageDB),
[storageDB, readonly]
);
const currentDiagram: Diagram = useMemo( const currentDiagram: Diagram = useMemo(
() => ({ () => ({
id: diagramId, id: diagramId,
@@ -1580,17 +1580,17 @@ export const ChartDBProvider: React.FC<
const updateDiagramData: ChartDBContext['updateDiagramData'] = useCallback( const updateDiagramData: ChartDBContext['updateDiagramData'] = useCallback(
async (diagram, options) => { async (diagram, options) => {
const st = options?.forceUpdateStorage ? dbStorage : db; const st = options?.forceUpdateStorage ? storageDB : db;
await st.deleteDiagram(diagram.id); await st.deleteDiagram(diagram.id);
await st.addDiagram({ diagram }); await st.addDiagram({ diagram });
loadDiagramFromData(diagram); loadDiagramFromData(diagram);
}, },
[db, dbStorage, loadDiagramFromData] [db, storageDB, loadDiagramFromData]
); );
const loadDiagram: ChartDBContext['loadDiagram'] = useCallback( const loadDiagram: ChartDBContext['loadDiagram'] = useCallback(
async (diagramId: string) => { async (diagramId: string) => {
const diagram = await db.getDiagram(diagramId, { const diagram = await storageDB.getDiagram(diagramId, {
includeRelationships: true, includeRelationships: true,
includeTables: true, includeTables: true,
includeDependencies: true, includeDependencies: true,
@@ -1604,7 +1604,7 @@ export const ChartDBProvider: React.FC<
return diagram; return diagram;
}, },
[db, loadDiagramFromData] [storageDB, loadDiagramFromData]
); );
// Custom type operations // Custom type operations

View File

@@ -3,7 +3,7 @@ import { Dialog, DialogContent } from '@/components/dialog/dialog';
import { DatabaseType } from '@/lib/domain/database-type'; import { DatabaseType } from '@/lib/domain/database-type';
import { useStorage } from '@/hooks/use-storage'; import { useStorage } from '@/hooks/use-storage';
import type { Diagram } from '@/lib/domain/diagram'; import type { Diagram } from '@/lib/domain/diagram';
import { loadFromDatabaseMetadata } from '@/lib/domain/diagram'; import { loadFromDatabaseMetadata } from '@/lib/data/import-metadata/import';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import type { DatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata'; import type { DatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata';

View File

@@ -69,6 +69,7 @@ export const SelectDatabase: React.FC<SelectDatabaseProps> = ({
type="button" type="button"
variant="outline" variant="outline"
onClick={createNewDiagram} onClick={createNewDiagram}
disabled={databaseType === DatabaseType.GENERIC}
> >
{t('new_diagram_dialog.empty_diagram')} {t('new_diagram_dialog.empty_diagram')}
</Button> </Button>

View File

@@ -17,7 +17,7 @@ import { useDialog } from '@/hooks/use-dialog';
import { import {
exportBaseSQL, exportBaseSQL,
exportSQL, exportSQL,
} from '@/lib/data/export-metadata/export-sql-script'; } from '@/lib/data/sql-export/export-sql-script';
import { databaseTypeToLabelMap } from '@/lib/databases'; import { databaseTypeToLabelMap } from '@/lib/databases';
import { DatabaseType } from '@/lib/domain/database-type'; import { DatabaseType } from '@/lib/domain/database-type';
import { Annoyed, Sparkles } from 'lucide-react'; import { Annoyed, Sparkles } from 'lucide-react';

View File

@@ -7,7 +7,7 @@ import type { DatabaseEdition } from '@/lib/domain/database-edition';
import type { DatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata'; import type { DatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata';
import { loadDatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata'; import { loadDatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata';
import type { Diagram } from '@/lib/domain/diagram'; import type { Diagram } from '@/lib/domain/diagram';
import { loadFromDatabaseMetadata } from '@/lib/domain/diagram'; import { loadFromDatabaseMetadata } from '@/lib/data/import-metadata/import';
import { useChartDB } from '@/hooks/use-chartdb'; import { useChartDB } from '@/hooks/use-chartdb';
import { useRedoUndoStack } from '@/hooks/use-redo-undo-stack'; import { useRedoUndoStack } from '@/hooks/use-redo-undo-stack';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';

View File

@@ -132,7 +132,7 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
const preprocessedContent = preprocessDBML(content); const preprocessedContent = preprocessDBML(content);
const sanitizedContent = sanitizeDBML(preprocessedContent); const sanitizedContent = sanitizeDBML(preprocessedContent);
const parser = new Parser(); const parser = new Parser();
parser.parse(sanitizedContent, 'dbml'); parser.parse(sanitizedContent, 'dbmlv2');
} catch (e) { } catch (e) {
const parsedError = parseDBMLError(e); const parsedError = parseDBMLError(e);
if (parsedError) { if (parsedError) {

View File

@@ -0,0 +1,98 @@
import React, { useCallback } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/dropdown-menu/dropdown-menu';
import { Button } from '@/components/button/button';
import { Ellipsis, Layers2, SquareArrowOutUpRight, Trash2 } from 'lucide-react';
import { useChartDB } from '@/hooks/use-chartdb';
import type { Diagram } from '@/lib/domain';
import { useStorage } from '@/hooks/use-storage';
import { cloneDiagram } from '@/lib/clone';
import { useTranslation } from 'react-i18next';
interface DiagramRowActionsMenuProps {
diagram: Diagram;
onOpen: () => void;
refetch: () => void;
numberOfDiagrams: number;
}
export const DiagramRowActionsMenu: React.FC<DiagramRowActionsMenuProps> = ({
diagram,
onOpen,
refetch,
numberOfDiagrams,
}) => {
const { diagramId } = useChartDB();
const { deleteDiagram, addDiagram } = useStorage();
const { t } = useTranslation();
const onDelete = useCallback(async () => {
deleteDiagram(diagram.id);
refetch();
if (diagram.id === diagramId || numberOfDiagrams <= 1) {
window.location.href = '/';
}
}, [deleteDiagram, diagram.id, diagramId, refetch, numberOfDiagrams]);
const onDuplicate = useCallback(async () => {
const duplicatedDiagram = cloneDiagram(diagram);
const diagramToAdd = duplicatedDiagram.diagram;
if (!diagramToAdd) {
return;
}
diagramToAdd.name = `${diagram.name} (Copy)`;
addDiagram({ diagram: diagramToAdd });
refetch();
}, [addDiagram, refetch, diagram]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 p-0"
onClick={(e) => e.stopPropagation()}
>
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={onOpen}
className="flex justify-between gap-4"
>
{t('open_diagram_dialog.diagram_actions.open')}
<SquareArrowOutUpRight className="size-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={onDuplicate}
className="flex justify-between gap-4"
>
{t('open_diagram_dialog.diagram_actions.duplicate')}
<Layers2 className="size-3.5" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className="flex justify-between gap-4 text-red-700"
>
{t('open_diagram_dialog.diagram_actions.delete')}
<Trash2 className="size-3.5 text-red-700" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -27,6 +27,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { BaseDialogProps } from '../common/base-dialog-props'; import type { BaseDialogProps } from '../common/base-dialog-props';
import { useDebounce } from '@/hooks/use-debounce'; import { useDebounce } from '@/hooks/use-debounce';
import { DiagramRowActionsMenu } from './diagram-row-actions-menu/diagram-row-actions-menu';
export interface OpenDiagramDialogProps extends BaseDialogProps { export interface OpenDiagramDialogProps extends BaseDialogProps {
canClose?: boolean; canClose?: boolean;
@@ -46,21 +47,22 @@ export const OpenDiagramDialog: React.FC<OpenDiagramDialogProps> = ({
string | undefined string | undefined
>(); >();
useEffect(() => { const fetchDiagrams = useCallback(async () => {
setSelectedDiagramId(undefined); const diagrams = await listDiagrams({ includeTables: true });
}, [dialog.open]); setDiagrams(
diagrams.sort(
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
)
);
}, [listDiagrams]);
useEffect(() => { useEffect(() => {
const fetchDiagrams = async () => { if (!dialog.open) {
const diagrams = await listDiagrams({ includeTables: true }); return;
setDiagrams( }
diagrams.sort( setSelectedDiagramId(undefined);
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
)
);
};
fetchDiagrams(); fetchDiagrams();
}, [listDiagrams, setDiagrams, dialog.open]); }, [dialog.open, fetchDiagrams]);
const openDiagram = useCallback( const openDiagram = useCallback(
(diagramId: string) => { (diagramId: string) => {
@@ -166,6 +168,7 @@ export const OpenDiagramDialog: React.FC<OpenDiagramDialogProps> = ({
'open_diagram_dialog.table_columns.tables_count' 'open_diagram_dialog.table_columns.tables_count'
)} )}
</TableHead> </TableHead>
<TableHead />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -221,6 +224,19 @@ export const OpenDiagramDialog: React.FC<OpenDiagramDialogProps> = ({
<TableCell className="text-center"> <TableCell className="text-center">
{diagram.tables?.length} {diagram.tables?.length}
</TableCell> </TableCell>
<TableCell className="items-center p-0 pr-1 text-right">
<DiagramRowActionsMenu
diagram={diagram}
onOpen={() => {
openDiagram(diagram.id);
closeOpenDiagramDialog();
}}
numberOfDiagrams={
diagrams.length
}
refetch={fetchDiagrams}
/>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

142
src/hooks/use-focus-on.ts Normal file
View File

@@ -0,0 +1,142 @@
import { useCallback } from 'react';
import { useReactFlow } from '@xyflow/react';
import { useLayout } from '@/hooks/use-layout';
import { useBreakpoint } from '@/hooks/use-breakpoint';
interface FocusOptions {
select?: boolean;
}
export const useFocusOn = () => {
const { fitView, setNodes, setEdges } = useReactFlow();
const { hideSidePanel } = useLayout();
const { isMd: isDesktop } = useBreakpoint('md');
const focusOnArea = useCallback(
(areaId: string, options: FocusOptions = {}) => {
const { select = true } = options;
if (select) {
setNodes((nodes) =>
nodes.map((node) =>
node.id === areaId
? {
...node,
selected: true,
}
: {
...node,
selected: false,
}
)
);
}
fitView({
duration: 500,
maxZoom: 1,
minZoom: 1,
nodes: [
{
id: areaId,
},
],
});
if (!isDesktop) {
hideSidePanel();
}
},
[fitView, setNodes, hideSidePanel, isDesktop]
);
const focusOnTable = useCallback(
(tableId: string, options: FocusOptions = {}) => {
const { select = true } = options;
if (select) {
setNodes((nodes) =>
nodes.map((node) =>
node.id === tableId
? {
...node,
selected: true,
}
: {
...node,
selected: false,
}
)
);
}
fitView({
duration: 500,
maxZoom: 1,
minZoom: 1,
nodes: [
{
id: tableId,
},
],
});
if (!isDesktop) {
hideSidePanel();
}
},
[fitView, setNodes, hideSidePanel, isDesktop]
);
const focusOnRelationship = useCallback(
(
relationshipId: string,
sourceTableId: string,
targetTableId: string,
options: FocusOptions = {}
) => {
const { select = true } = options;
if (select) {
setEdges((edges) =>
edges.map((edge) =>
edge.id === relationshipId
? {
...edge,
selected: true,
}
: {
...edge,
selected: false,
}
)
);
}
fitView({
duration: 500,
maxZoom: 1,
minZoom: 1,
nodes: [
{
id: sourceTableId,
},
{
id: targetTableId,
},
],
});
if (!isDesktop) {
hideSidePanel();
}
},
[fitView, setEdges, hideSidePanel, isDesktop]
);
return {
focusOnArea,
focusOnTable,
focusOnRelationship,
};
};

View File

@@ -12,15 +12,15 @@ export const ar: LanguageTranslation = {
custom_types: 'الأنواع المخصصة', custom_types: 'الأنواع المخصصة',
}, },
menu: { menu: {
databases: { actions: {
databases: 'قواعد البيانات', actions: 'الإجراءات',
new: 'مخطط جديد', new: 'جديد...',
browse: 'تصفح...', browse: 'تصفح...',
save: 'حفظ', save: 'حفظ',
import: 'استيراد قاعدة بيانات', import: 'استيراد قاعدة بيانات',
export_sql: 'SQL تصدير', export_sql: 'SQL تصدير',
export_as: 'تصدير كـ', export_as: 'تصدير كـ',
delete_diagram: 'حذف الرسم البياني', delete_diagram: 'حذف',
}, },
edit: { edit: {
edit: 'تحرير', edit: 'تحرير',
@@ -74,10 +74,10 @@ export const ar: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'إعادة ترتيب الرسم البياني', title: 'ترتيب تلقائي للرسم البياني',
description: description:
'هذا الإجراء سيقوم بإعادة ترتيب الجداول في المخطط بشكل تلقائي. هل تريد المتابعة؟', 'هذا الإجراء سيقوم بإعادة ترتيب الجداول في المخطط بشكل تلقائي. هل تريد المتابعة؟',
reorder: 'إعادة ترتيب', reorder: 'ترتيب تلقائي',
cancel: 'إلغاء', cancel: 'إلغاء',
}, },
@@ -248,6 +248,7 @@ export const ar: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'لم يتم تحديد قيم التعداد',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -270,7 +271,7 @@ export const ar: LanguageTranslation = {
show_all: 'عرض الكل', show_all: 'عرض الكل',
undo: 'تراجع', undo: 'تراجع',
redo: 'إعادة', redo: 'إعادة',
reorder_diagram: 'إعادة ترتيب الرسم البياني', reorder_diagram: 'ترتيب تلقائي للرسم البياني',
highlight_overlapping_tables: 'تمييز الجداول المتداخلة', highlight_overlapping_tables: 'تمييز الجداول المتداخلة',
// TODO: Translate // TODO: Translate
filter: 'Filter Tables', filter: 'Filter Tables',
@@ -313,7 +314,7 @@ export const ar: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'فتح مخطط', title: 'فتح قاعدة بيانات',
description: 'اختر مخططًا لفتحه من القائمة ادناه', description: 'اختر مخططًا لفتحه من القائمة ادناه',
table_columns: { table_columns: {
name: 'الإسم', name: 'الإسم',
@@ -323,6 +324,12 @@ export const ar: LanguageTranslation = {
}, },
cancel: 'إلغاء', cancel: 'إلغاء',
open: 'فتح', open: 'فتح',
diagram_actions: {
open: 'فتح',
duplicate: 'تكرار',
delete: 'حذف',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const bn: LanguageTranslation = {
custom_types: 'কাস্টম টাইপ', custom_types: 'কাস্টম টাইপ',
}, },
menu: { menu: {
databases: { actions: {
databases: 'ডাটাবেস', actions: 'কার্য',
new: 'নতুন ডায়াগ্রাম', new: 'নতুন...',
browse: 'ব্রাউজ করুন...', browse: 'ব্রাউজ করুন...',
save: 'সংরক্ষণ করুন', save: 'সংরক্ষণ করুন',
import: 'ডাটাবেস আমদানি করুন', import: 'ডাটাবেস আমদানি করুন',
export_sql: 'SQL রপ্তানি করুন', export_sql: 'SQL রপ্তানি করুন',
export_as: 'রূপে রপ্তানি করুন', export_as: 'রূপে রপ্তানি করুন',
delete_diagram: 'ডায়াগ্রাম মুছুন', delete_diagram: 'মুছুন',
}, },
edit: { edit: {
edit: 'সম্পাদনা', edit: 'সম্পাদনা',
@@ -75,10 +75,10 @@ export const bn: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'ডায়াগ্রাম পুনর্বিন্যাস করুন', title: 'স্বয়ংক্রিয় ডায়াগ্রাম সাজান',
description: description:
'এই কাজটি ডায়াগ্রামের সমস্ত টেবিল পুনর্বিন্যাস করবে। আপনি কি চালিয়ে যেতে চান?', 'এই কাজটি ডায়াগ্রামের সমস্ত টেবিল পুনর্বিন্যাস করবে। আপনি কি চালিয়ে যেতে চান?',
reorder: 'পুনর্বিন্যাস করুন', reorder: 'স্বয়ংক্রিয় সাজান',
cancel: 'বাতিল করুন', cancel: 'বাতিল করুন',
}, },
@@ -249,6 +249,7 @@ export const bn: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'কোন enum মান সংজ্ঞায়িত নেই',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -271,7 +272,7 @@ export const bn: LanguageTranslation = {
show_all: 'সব দেখান', show_all: 'সব দেখান',
undo: 'পূর্বাবস্থায় ফিরুন', undo: 'পূর্বাবস্থায় ফিরুন',
redo: 'পুনরায় করুন', redo: 'পুনরায় করুন',
reorder_diagram: 'ডায়াগ্রাম পুনর্বিন্যাস করুন', reorder_diagram: 'স্বয়ংক্রিয় ডায়াগ্রাম সাজান',
highlight_overlapping_tables: 'ওভারল্যাপিং টেবিল হাইলাইট করুন', highlight_overlapping_tables: 'ওভারল্যাপিং টেবিল হাইলাইট করুন',
// TODO: Translate // TODO: Translate
@@ -315,7 +316,7 @@ export const bn: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'চিত্র খুলুন', title: 'ডেটাবেস খুলুন',
description: 'নিচের তালিকা থেকে একটি চিত্র নির্বাচন করুন।', description: 'নিচের তালিকা থেকে একটি চিত্র নির্বাচন করুন।',
table_columns: { table_columns: {
name: 'নাম', name: 'নাম',
@@ -325,6 +326,12 @@ export const bn: LanguageTranslation = {
}, },
cancel: 'বাতিল করুন', cancel: 'বাতিল করুন',
open: 'খুলুন', open: 'খুলুন',
diagram_actions: {
open: 'খুলুন',
duplicate: 'ডুপ্লিকেট',
delete: 'মুছুন',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const de: LanguageTranslation = {
custom_types: 'Benutzerdefinierte Typen', custom_types: 'Benutzerdefinierte Typen',
}, },
menu: { menu: {
databases: { actions: {
databases: 'Datenbanken', actions: 'Aktionen',
new: 'Neues Diagramm', new: 'Neu...',
browse: 'Durchsuchen...', browse: 'Durchsuchen...',
save: 'Speichern', save: 'Speichern',
import: 'Datenbank importieren', import: 'Datenbank importieren',
export_sql: 'SQL exportieren', export_sql: 'SQL exportieren',
export_as: 'Exportieren als', export_as: 'Exportieren als',
delete_diagram: 'Diagramm löschen', delete_diagram: 'Löschen',
}, },
edit: { edit: {
edit: 'Bearbeiten', edit: 'Bearbeiten',
@@ -75,10 +75,10 @@ export const de: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'Diagramm neu anordnen', title: 'Diagramm automatisch anordnen',
description: description:
'Diese Aktion wird alle Tabellen im Diagramm neu anordnen. Möchten Sie fortfahren?', 'Diese Aktion wird alle Tabellen im Diagramm neu anordnen. Möchten Sie fortfahren?',
reorder: 'Neu anordnen', reorder: 'Automatisch anordnen',
cancel: 'Abbrechen', cancel: 'Abbrechen',
}, },
@@ -250,6 +250,7 @@ export const de: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'Keine Enum-Werte definiert',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -272,7 +273,7 @@ export const de: LanguageTranslation = {
show_all: 'Alle anzeigen', show_all: 'Alle anzeigen',
undo: 'Rückgängig', undo: 'Rückgängig',
redo: 'Wiederholen', redo: 'Wiederholen',
reorder_diagram: 'Diagramm neu anordnen', reorder_diagram: 'Diagramm automatisch anordnen',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
@@ -318,7 +319,7 @@ export const de: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'Diagramm öffnen', title: 'Datenbank öffnen',
description: 'Wählen Sie ein Diagramm aus der Liste unten aus.', description: 'Wählen Sie ein Diagramm aus der Liste unten aus.',
table_columns: { table_columns: {
name: 'Name', name: 'Name',
@@ -328,6 +329,12 @@ export const de: LanguageTranslation = {
}, },
cancel: 'Abbrechen', cancel: 'Abbrechen',
open: 'Öffnen', open: 'Öffnen',
diagram_actions: {
open: 'Öffnen',
duplicate: 'Duplizieren',
delete: 'Löschen',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const en = {
custom_types: 'Custom Types', custom_types: 'Custom Types',
}, },
menu: { menu: {
databases: { actions: {
databases: 'Databases', actions: 'Actions',
new: 'New Diagram', new: 'New...',
browse: 'Browse...', browse: 'Browse...',
save: 'Save', save: 'Save',
import: 'Import', import: 'Import',
export_sql: 'Export SQL', export_sql: 'Export SQL',
export_as: 'Export as', export_as: 'Export as',
delete_diagram: 'Delete Diagram', delete_diagram: 'Delete',
}, },
edit: { edit: {
edit: 'Edit', edit: 'Edit',
@@ -73,10 +73,10 @@ export const en = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'Reorder Diagram', title: 'Auto Arrange Diagram',
description: description:
'This action will rearrange all tables in the diagram. Do you want to continue?', 'This action will rearrange all tables in the diagram. Do you want to continue?',
reorder: 'Reorder', reorder: 'Auto Arrange',
cancel: 'Cancel', cancel: 'Cancel',
}, },
@@ -242,6 +242,7 @@ export const en = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'No enum values defined',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -264,7 +265,7 @@ export const en = {
show_all: 'Show All', show_all: 'Show All',
undo: 'Undo', undo: 'Undo',
redo: 'Redo', redo: 'Redo',
reorder_diagram: 'Reorder Diagram', reorder_diagram: 'Auto Arrange Diagram',
highlight_overlapping_tables: 'Highlight Overlapping Tables', highlight_overlapping_tables: 'Highlight Overlapping Tables',
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -306,7 +307,7 @@ export const en = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'Open Diagram', title: 'Open Database',
description: 'Select a diagram to open from the list below.', description: 'Select a diagram to open from the list below.',
table_columns: { table_columns: {
name: 'Name', name: 'Name',
@@ -316,6 +317,12 @@ export const en = {
}, },
cancel: 'Cancel', cancel: 'Cancel',
open: 'Open', open: 'Open',
diagram_actions: {
open: 'Open',
duplicate: 'Duplicate',
delete: 'Delete',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const es: LanguageTranslation = {
custom_types: 'Tipos Personalizados', custom_types: 'Tipos Personalizados',
}, },
menu: { menu: {
databases: { actions: {
databases: 'Bases de Datos', actions: 'Acciones',
new: 'Nuevo Diagrama', new: 'Nuevo...',
browse: 'Examinar...', browse: 'Examinar...',
save: 'Guardar', save: 'Guardar',
import: 'Importar Base de Datos', import: 'Importar Base de Datos',
export_sql: 'Exportar SQL', export_sql: 'Exportar SQL',
export_as: 'Exportar como', export_as: 'Exportar como',
delete_diagram: 'Eliminar Diagrama', delete_diagram: 'Eliminar',
}, },
edit: { edit: {
edit: 'Editar', edit: 'Editar',
@@ -74,10 +74,10 @@ export const es: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'Reordenar Diagrama', title: 'Organizar Diagrama Automáticamente',
description: description:
'Esta acción reorganizará todas las tablas en el diagrama. ¿Deseas continuar?', 'Esta acción reorganizará todas las tablas en el diagrama. ¿Deseas continuar?',
reorder: 'Reordenar', reorder: 'Organizar Automáticamente',
cancel: 'Cancelar', cancel: 'Cancelar',
}, },
@@ -248,6 +248,7 @@ export const es: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'No hay valores de enum definidos',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -270,7 +271,7 @@ export const es: LanguageTranslation = {
show_all: 'Mostrar Todo', show_all: 'Mostrar Todo',
undo: 'Deshacer', undo: 'Deshacer',
redo: 'Rehacer', redo: 'Rehacer',
reorder_diagram: 'Reordenar Diagrama', reorder_diagram: 'Organizar Diagrama Automáticamente',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -315,7 +316,7 @@ export const es: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'Abrir Diagrama', title: 'Abrir Base de Datos',
description: description:
'Selecciona un diagrama para abrir de la lista a continuación.', 'Selecciona un diagrama para abrir de la lista a continuación.',
table_columns: { table_columns: {
@@ -326,6 +327,12 @@ export const es: LanguageTranslation = {
}, },
cancel: 'Cancelar', cancel: 'Cancelar',
open: 'Abrir', open: 'Abrir',
diagram_actions: {
open: 'Abrir',
duplicate: 'Duplicar',
delete: 'Eliminar',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const fr: LanguageTranslation = {
custom_types: 'Types Personnalisés', custom_types: 'Types Personnalisés',
}, },
menu: { menu: {
databases: { actions: {
databases: 'Bases de Données', actions: 'Actions',
new: 'Nouveau Diagramme', new: 'Nouveau...',
browse: 'Parcourir...', browse: 'Parcourir...',
save: 'Enregistrer', save: 'Enregistrer',
import: 'Importer Base de Données', import: 'Importer Base de Données',
export_sql: 'Exporter SQL', export_sql: 'Exporter SQL',
export_as: 'Exporter en tant que', export_as: 'Exporter en tant que',
delete_diagram: 'Supprimer le Diagramme', delete_diagram: 'Supprimer',
}, },
edit: { edit: {
edit: 'Édition', edit: 'Édition',
@@ -73,10 +73,10 @@ export const fr: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'Réorganiser le Diagramme', title: 'Organiser Automatiquement le Diagramme',
description: description:
'Cette action réorganisera toutes les tables dans le diagramme. Voulez-vous continuer ?', 'Cette action réorganisera toutes les tables dans le diagramme. Voulez-vous continuer ?',
reorder: 'Réorganiser', reorder: 'Organiser Automatiquement',
cancel: 'Annuler', cancel: 'Annuler',
}, },
@@ -246,6 +246,7 @@ export const fr: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: "Aucune valeur d'énumération définie",
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -268,7 +269,7 @@ export const fr: LanguageTranslation = {
show_all: 'Afficher Tout', show_all: 'Afficher Tout',
undo: 'Annuler', undo: 'Annuler',
redo: 'Rétablir', redo: 'Rétablir',
reorder_diagram: 'Réorganiser le Diagramme', reorder_diagram: 'Organiser Automatiquement le Diagramme',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -312,7 +313,7 @@ export const fr: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'Ouvrir Diagramme', title: 'Ouvrir Base de Données',
description: description:
'Sélectionnez un diagramme à ouvrir dans la liste ci-dessous.', 'Sélectionnez un diagramme à ouvrir dans la liste ci-dessous.',
table_columns: { table_columns: {
@@ -323,6 +324,12 @@ export const fr: LanguageTranslation = {
}, },
cancel: 'Annuler', cancel: 'Annuler',
open: 'Ouvrir', open: 'Ouvrir',
diagram_actions: {
open: 'Ouvrir',
duplicate: 'Dupliquer',
delete: 'Supprimer',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const gu: LanguageTranslation = {
custom_types: 'કસ્ટમ ટાઇપ', custom_types: 'કસ્ટમ ટાઇપ',
}, },
menu: { menu: {
databases: { actions: {
databases: 'ડેટાબેસેસ', actions: 'ક્રિયાઓ',
new: 'નવું ડાયાગ્રામ', new: 'નવું...',
browse: 'બ્રાઉજ કરો...', browse: 'બ્રાઉજ કરો...',
save: 'સાચવો', save: 'સાચવો',
import: 'ડેટાબેસ આયાત કરો', import: 'ડેટાબેસ આયાત કરો',
export_sql: 'SQL નિકાસ કરો', export_sql: 'SQL નિકાસ કરો',
export_as: 'રૂપે નિકાસ કરો', export_as: 'રૂપે નિકાસ કરો',
delete_diagram: 'ડાયાગ્રામ કાઢી નાખો', delete_diagram: 'કાઢી નાખો',
}, },
edit: { edit: {
edit: 'ફેરફાર', edit: 'ફેરફાર',
@@ -75,10 +75,10 @@ export const gu: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'ડાયાગ્રામ ફરી વ્યવસ્થિત કરો', title: 'ડાયાગ્રામ ઑટોમેટિક ગોઠવો',
description: description:
'આ ક્રિયા ડાયાગ્રામમાં બધી ટેબલ્સને ફરીથી વ્યવસ્થિત કરશે. શું તમે ચાલુ રાખવા માંગો છો?', 'આ ક્રિયા ડાયાગ્રામમાં બધી ટેબલ્સને ફરીથી વ્યવસ્થિત કરશે. શું તમે ચાલુ રાખવા માંગો છો?',
reorder: 'ફરી વ્યવસ્થિત કરો', reorder: 'ઑટોમેટિક ગોઠવો',
cancel: 'રદ કરો', cancel: 'રદ કરો',
}, },
@@ -250,6 +250,7 @@ export const gu: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'કોઈ enum મૂલ્યો વ્યાખ્યાયિત નથી',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -272,7 +273,7 @@ export const gu: LanguageTranslation = {
show_all: 'બધું બતાવો', show_all: 'બધું બતાવો',
undo: 'અનડુ', undo: 'અનડુ',
redo: 'રીડુ', redo: 'રીડુ',
reorder_diagram: 'ડાયાગ્રામ ફરીથી વ્યવસ્થિત કરો', reorder_diagram: 'ડાયાગ્રામ ઑટોમેટિક ગોઠવો',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -315,7 +316,7 @@ export const gu: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'ડાયાગ્રામ ખોલો', title: 'ડેટાબેસ ખોલો',
description: 'નીચેની યાદીમાંથી એક ડાયાગ્રામ પસંદ કરો.', description: 'નીચેની યાદીમાંથી એક ડાયાગ્રામ પસંદ કરો.',
table_columns: { table_columns: {
name: 'નામ', name: 'નામ',
@@ -325,6 +326,12 @@ export const gu: LanguageTranslation = {
}, },
cancel: 'રદ કરો', cancel: 'રદ કરો',
open: 'ખોલો', open: 'ખોલો',
diagram_actions: {
open: 'ખોલો',
duplicate: 'ડુપ્લિકેટ',
delete: 'કાઢી નાખો',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const hi: LanguageTranslation = {
custom_types: 'कस्टम टाइप', custom_types: 'कस्टम टाइप',
}, },
menu: { menu: {
databases: { actions: {
databases: 'डेटाबेस', actions: 'कार्य',
new: 'नया आरेख', new: 'नया...',
browse: 'ब्राउज़ करें...', browse: 'ब्राउज़ करें...',
save: 'सहेजें', save: 'सहेजें',
import: 'डेटाबेस आयात करें', import: 'डेटाबेस आयात करें',
export_sql: 'SQL निर्यात करें', export_sql: 'SQL निर्यात करें',
export_as: 'के रूप में निर्यात करें', export_as: 'के रूप में निर्यात करें',
delete_diagram: 'आरेख हटाएँ', delete_diagram: 'हटाएँ',
}, },
edit: { edit: {
edit: 'संपादित करें', edit: 'संपादित करें',
@@ -74,10 +74,10 @@ export const hi: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'आरेख पुनः व्यवस्थित करें', title: 'आरेख स्वचालित व्यवस्थित करें',
description: description:
'यह क्रिया आरेख में सभी तालिकाओं को पुनः व्यवस्थित कर देगी। क्या आप जारी रखना चाहते हैं?', 'यह क्रिया आरेख में सभी तालिकाओं को पुनः व्यवस्थित कर देगी। क्या आप जारी रखना चाहते हैं?',
reorder: 'पुनः व्यवस्थित करें', reorder: 'स्वचालित व्यवस्थित करें',
cancel: 'रद्द करें', cancel: 'रद्द करें',
}, },
@@ -249,6 +249,7 @@ export const hi: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'कोई enum मान परिभाषित नहीं',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -271,7 +272,7 @@ export const hi: LanguageTranslation = {
show_all: 'सभी दिखाएँ', show_all: 'सभी दिखाएँ',
undo: 'पूर्ववत करें', undo: 'पूर्ववत करें',
redo: 'पुनः करें', redo: 'पुनः करें',
reorder_diagram: 'आरेख पुनः व्यवस्थित करें', reorder_diagram: 'आरेख स्वचालित व्यवस्थित करें',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -317,7 +318,7 @@ export const hi: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'आरेख खोलें', title: 'डेटाबेस खोलें',
description: 'नीचे दी गई सूची से एक आरेख चुनें।', description: 'नीचे दी गई सूची से एक आरेख चुनें।',
table_columns: { table_columns: {
name: 'नाम', name: 'नाम',
@@ -327,6 +328,12 @@ export const hi: LanguageTranslation = {
}, },
cancel: 'रद्द करें', cancel: 'रद्द करें',
open: 'खोलें', open: 'खोलें',
diagram_actions: {
open: 'खोलें',
duplicate: 'डुप्लिकेट',
delete: 'हटाएं',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const hr: LanguageTranslation = {
custom_types: 'Prilagođeni Tipovi', custom_types: 'Prilagođeni Tipovi',
}, },
menu: { menu: {
databases: { actions: {
databases: 'Baze Podataka', actions: 'Akcije',
new: 'Novi Dijagram', new: 'Novi...',
browse: 'Pregledaj...', browse: 'Pregledaj...',
save: 'Spremi', save: 'Spremi',
import: 'Uvezi', import: 'Uvezi',
export_sql: 'Izvezi SQL', export_sql: 'Izvezi SQL',
export_as: 'Izvezi kao', export_as: 'Izvezi kao',
delete_diagram: 'Izbriši dijagram', delete_diagram: 'Izbriši',
}, },
edit: { edit: {
edit: 'Uredi', edit: 'Uredi',
@@ -73,10 +73,10 @@ export const hr: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'Preuredi dijagram', title: 'Automatski preuredi dijagram',
description: description:
'Ova radnja će preurediti sve tablice u dijagramu. Želite li nastaviti?', 'Ova radnja će preurediti sve tablice u dijagramu. Želite li nastaviti?',
reorder: 'Preuredi', reorder: 'Automatski preuredi',
cancel: 'Odustani', cancel: 'Odustani',
}, },
@@ -245,6 +245,7 @@ export const hr: LanguageTranslation = {
enum_values: 'Enum vrijednosti', enum_values: 'Enum vrijednosti',
composite_fields: 'Polja', composite_fields: 'Polja',
no_fields: 'Nema definiranih polja', no_fields: 'Nema definiranih polja',
no_values: 'Nema definiranih enum vrijednosti',
field_name_placeholder: 'Naziv polja', field_name_placeholder: 'Naziv polja',
field_type_placeholder: 'Odaberi tip', field_type_placeholder: 'Odaberi tip',
add_field: 'Dodaj polje', add_field: 'Dodaj polje',
@@ -268,7 +269,7 @@ export const hr: LanguageTranslation = {
show_all: 'Prikaži sve', show_all: 'Prikaži sve',
undo: 'Poništi', undo: 'Poništi',
redo: 'Ponovi', redo: 'Ponovi',
reorder_diagram: 'Preuredi dijagram', reorder_diagram: 'Automatski preuredi dijagram',
highlight_overlapping_tables: 'Istakni preklapajuće tablice', highlight_overlapping_tables: 'Istakni preklapajuće tablice',
clear_custom_type_highlight: 'Ukloni isticanje za "{{typeName}}"', clear_custom_type_highlight: 'Ukloni isticanje za "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -310,7 +311,7 @@ export const hr: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'Otvori dijagram', title: 'Otvori bazu podataka',
description: 'Odaberite dijagram za otvaranje iz popisa ispod.', description: 'Odaberite dijagram za otvaranje iz popisa ispod.',
table_columns: { table_columns: {
name: 'Naziv', name: 'Naziv',
@@ -320,6 +321,12 @@ export const hr: LanguageTranslation = {
}, },
cancel: 'Odustani', cancel: 'Odustani',
open: 'Otvori', open: 'Otvori',
diagram_actions: {
open: 'Otvori',
duplicate: 'Dupliciraj',
delete: 'Obriši',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const id_ID: LanguageTranslation = {
custom_types: 'Tipe Kustom', custom_types: 'Tipe Kustom',
}, },
menu: { menu: {
databases: { actions: {
databases: 'Basis Data', actions: 'Aksi',
new: 'Diagram Baru', new: 'Baru...',
browse: 'Jelajahi...', browse: 'Jelajahi...',
save: 'Simpan', save: 'Simpan',
import: 'Impor Database', import: 'Impor Database',
export_sql: 'Ekspor SQL', export_sql: 'Ekspor SQL',
export_as: 'Ekspor Sebagai', export_as: 'Ekspor Sebagai',
delete_diagram: 'Hapus Diagram', delete_diagram: 'Hapus',
}, },
edit: { edit: {
edit: 'Ubah', edit: 'Ubah',
@@ -74,10 +74,10 @@ export const id_ID: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'Atur Ulang Diagram', title: 'Atur Otomatis Diagram',
description: description:
'Tindakan ini akan mengatur ulang semua tabel di diagram. Apakah Anda ingin melanjutkan?', 'Tindakan ini akan mengatur ulang semua tabel di diagram. Apakah Anda ingin melanjutkan?',
reorder: 'Atur Ulang', reorder: 'Atur Otomatis',
cancel: 'Batal', cancel: 'Batal',
}, },
@@ -248,6 +248,7 @@ export const id_ID: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'Tidak ada nilai enum yang ditentukan',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -270,7 +271,7 @@ export const id_ID: LanguageTranslation = {
show_all: 'Tampilkan Semua', show_all: 'Tampilkan Semua',
undo: 'Undo', undo: 'Undo',
redo: 'Redo', redo: 'Redo',
reorder_diagram: 'Atur Ulang Diagram', reorder_diagram: 'Atur Otomatis Diagram',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -314,7 +315,7 @@ export const id_ID: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'Buka Diagram', title: 'Buka Database',
description: 'Pilih diagram untuk dibuka dari daftar di bawah.', description: 'Pilih diagram untuk dibuka dari daftar di bawah.',
table_columns: { table_columns: {
name: 'Name', name: 'Name',
@@ -324,6 +325,12 @@ export const id_ID: LanguageTranslation = {
}, },
cancel: 'Batal', cancel: 'Batal',
open: 'Buka', open: 'Buka',
diagram_actions: {
open: 'Buka',
duplicate: 'Duplikat',
delete: 'Hapus',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const ja: LanguageTranslation = {
custom_types: 'カスタムタイプ', custom_types: 'カスタムタイプ',
}, },
menu: { menu: {
databases: { actions: {
databases: 'データベース', actions: 'アクション',
new: '新しいダイアグラム', new: '新規...',
browse: '参照...', browse: '参照...',
save: '保存', save: '保存',
import: 'データベースをインポート', import: 'データベースをインポート',
export_sql: 'SQLをエクスポート', export_sql: 'SQLをエクスポート',
export_as: '形式を指定してエクスポート', export_as: '形式を指定してエクスポート',
delete_diagram: 'ダイアグラムを削除', delete_diagram: '削除',
}, },
edit: { edit: {
edit: '編集', edit: '編集',
@@ -76,10 +76,10 @@ export const ja: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'ダイアグラムを並べ替え', title: 'ダイアグラムを自動配置',
description: description:
'この操作によりダイアグラム内のすべてのテーブルが再配置されます。続行しますか?', 'この操作によりダイアグラム内のすべてのテーブルが再配置されます。続行しますか?',
reorder: '並べ替え', reorder: '自動配置',
cancel: 'キャンセル', cancel: 'キャンセル',
}, },
@@ -253,6 +253,7 @@ export const ja: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: '列挙値が定義されていません',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -275,7 +276,7 @@ export const ja: LanguageTranslation = {
show_all: 'すべて表示', show_all: 'すべて表示',
undo: '元に戻す', undo: '元に戻す',
redo: 'やり直し', redo: 'やり直し',
reorder_diagram: 'ダイアグラムを並べ替え', reorder_diagram: 'ダイアグラムを自動配置',
// TODO: Translate // TODO: Translate
highlight_overlapping_tables: 'Highlight Overlapping Tables', highlight_overlapping_tables: 'Highlight Overlapping Tables',
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
@@ -319,7 +320,7 @@ export const ja: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'ダイアグラムを開く', title: 'データベースを開く',
description: '以下のリストからダイアグラムを選択してください。', description: '以下のリストからダイアグラムを選択してください。',
table_columns: { table_columns: {
name: '名前', name: '名前',
@@ -329,6 +330,12 @@ export const ja: LanguageTranslation = {
}, },
cancel: 'キャンセル', cancel: 'キャンセル',
open: '開く', open: '開く',
diagram_actions: {
open: '開く',
duplicate: '複製',
delete: '削除',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const ko_KR: LanguageTranslation = {
custom_types: '사용자 지정 타입', custom_types: '사용자 지정 타입',
}, },
menu: { menu: {
databases: { actions: {
databases: '데이터베이스', actions: '작업',
new: '새 다이어그램', new: '새로 만들기...',
browse: '찾아보기...', browse: '찾아보기...',
save: '저장', save: '저장',
import: '데이터베이스 가져오기', import: '데이터베이스 가져오기',
export_sql: 'SQL로 저장', export_sql: 'SQL로 저장',
export_as: '다른 형식으로 저장', export_as: '다른 형식으로 저장',
delete_diagram: '다이어그램 삭제', delete_diagram: '삭제',
}, },
edit: { edit: {
edit: '편집', edit: '편집',
@@ -74,10 +74,10 @@ export const ko_KR: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: '다이어그램 정렬', title: '다이어그램 자동 정렬',
description: description:
'이 작업은 모든 다이어그램이 재정렬됩니다. 계속하시겠습니까?', '이 작업은 모든 다이어그램이 재정렬됩니다. 계속하시겠습니까?',
reorder: '정렬', reorder: '자동 정렬',
cancel: '취소', cancel: '취소',
}, },
@@ -248,6 +248,7 @@ export const ko_KR: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: '정의된 열거형 값이 없습니다',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -270,7 +271,7 @@ export const ko_KR: LanguageTranslation = {
show_all: '전체 저장', show_all: '전체 저장',
undo: '실행 취소', undo: '실행 취소',
redo: '다시 실행', redo: '다시 실행',
reorder_diagram: '다이어그램 정렬', reorder_diagram: '다이어그램 자동 정렬',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -314,7 +315,7 @@ export const ko_KR: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: '다이어그램 열기', title: '데이터베이스 열기',
description: '아래의 목록에서 다이어그램을 선택하세요.', description: '아래의 목록에서 다이어그램을 선택하세요.',
table_columns: { table_columns: {
name: '이름', name: '이름',
@@ -324,6 +325,12 @@ export const ko_KR: LanguageTranslation = {
}, },
cancel: '취소', cancel: '취소',
open: '열기', open: '열기',
diagram_actions: {
open: '열기',
duplicate: '복제',
delete: '삭제',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const mr: LanguageTranslation = {
custom_types: 'कस्टम प्रकार', custom_types: 'कस्टम प्रकार',
}, },
menu: { menu: {
databases: { actions: {
databases: 'डेटाबेस', actions: 'क्रिया',
new: 'नवीन आरेख', new: 'नवीन...',
browse: 'ब्राउज करा...', browse: 'ब्राउज करा...',
save: 'जतन करा', save: 'जतन करा',
import: 'डेटाबेस इम्पोर्ट करा', import: 'डेटाबेस इम्पोर्ट करा',
export_sql: 'SQL एक्स्पोर्ट करा', export_sql: 'SQL एक्स्पोर्ट करा',
export_as: 'म्हणून एक्स्पोर्ट करा', export_as: 'म्हणून एक्स्पोर्ट करा',
delete_diagram: 'आरेख हटवा', delete_diagram: 'हटवा',
}, },
edit: { edit: {
edit: 'संपादन करा', edit: 'संपादन करा',
@@ -75,10 +75,10 @@ export const mr: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'आरेख पुनःक्रमित करा', title: 'आरेख स्वयंचलित व्यवस्थित करा',
description: description:
'ही क्रिया आरेखातील सर्व टेबल्सची पुनर्रचना करेल. तुम्हाला पुढे जायचे आहे का?', 'ही क्रिया आरेखातील सर्व टेबल्सची पुनर्रचना करेल. तुम्हाला पुढे जायचे आहे का?',
reorder: 'पुनःक्रमित करा', reorder: 'स्वयंचलित व्यवस्थित करा',
cancel: 'रद्द करा', cancel: 'रद्द करा',
}, },
@@ -252,6 +252,7 @@ export const mr: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'कोणतीही enum मूल्ये परिभाषित नाहीत',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -274,7 +275,7 @@ export const mr: LanguageTranslation = {
show_all: 'सर्व दाखवा', show_all: 'सर्व दाखवा',
undo: 'पूर्ववत करा', undo: 'पूर्ववत करा',
redo: 'पुन्हा करा', redo: 'पुन्हा करा',
reorder_diagram: 'आरेख पुनःक्रमित करा', reorder_diagram: 'आरेख स्वयंचलित व्यवस्थित करा',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -320,7 +321,7 @@ export const mr: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'आरेख उघडा', title: 'डेटाबेस उघडा',
description: 'खालील यादीतून उघडण्यासाठी एक आरेख निवडा.', description: 'खालील यादीतून उघडण्यासाठी एक आरेख निवडा.',
table_columns: { table_columns: {
name: 'नाव', name: 'नाव',
@@ -330,6 +331,12 @@ export const mr: LanguageTranslation = {
}, },
cancel: 'रद्द करा', cancel: 'रद्द करा',
open: 'उघडा', open: 'उघडा',
diagram_actions: {
open: 'उघडा',
duplicate: 'डुप्लिकेट',
delete: 'हटवा',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const ne: LanguageTranslation = {
custom_types: 'कस्टम प्रकारहरू', custom_types: 'कस्टम प्रकारहरू',
}, },
menu: { menu: {
databases: { actions: {
databases: 'डाटाबेसहरू', actions: 'कार्यहरू',
new: 'नयाँ डायाग्राम', new: 'नयाँ...',
browse: 'ब्राउज गर्नुहोस्...', browse: 'ब्राउज गर्नुहोस्...',
save: 'सुरक्षित गर्नुहोस्', save: 'सुरक्षित गर्नुहोस्',
import: 'डाटाबेस आयात गर्नुहोस्', import: 'डाटाबेस आयात गर्नुहोस्',
export_sql: 'SQL निर्यात गर्नुहोस्', export_sql: 'SQL निर्यात गर्नुहोस्',
export_as: 'निर्यात गर्नुहोस्', export_as: 'निर्यात गर्नुहोस्',
delete_diagram: 'डायाग्राम हटाउनुहोस्', delete_diagram: 'हटाउनुहोस्',
}, },
edit: { edit: {
edit: 'सम्पादन', edit: 'सम्पादन',
@@ -75,10 +75,10 @@ export const ne: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'डायाग्राम पुनः क्रमबद्ध गर्नुहोस्', title: 'डायाग्राम स्वचालित मिलाउनुहोस्',
description: description:
'यो कार्य पूर्ववत गर्न सकिँदैन। यो डायाग्राम स्थायी रूपमा हटाउनेछ।', 'यो कार्य पूर्ववत गर्न सकिँदैन। यो डायाग्राम स्थायी रूपमा हटाउनेछ।',
reorder: 'पुनः क्रमबद्ध गर्नुहोस्', reorder: 'स्वचालित मिलाउनुहोस्',
cancel: 'रद्द गर्नुहोस्', cancel: 'रद्द गर्नुहोस्',
}, },
@@ -249,6 +249,7 @@ export const ne: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'कुनै enum मानहरू परिभाषित छैनन्',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -271,7 +272,7 @@ export const ne: LanguageTranslation = {
show_all: 'सबै देखाउनुहोस्', show_all: 'सबै देखाउनुहोस्',
undo: 'पूर्ववत', undo: 'पूर्ववत',
redo: 'पुनः गर्नुहोस्', redo: 'पुनः गर्नुहोस्',
reorder_diagram: 'पुनः क्रमबद्ध गर्नुहोस्', reorder_diagram: 'डायाग्राम स्वचालित मिलाउनुहोस्',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -316,7 +317,7 @@ export const ne: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'डायाग्राम खोल्नुहोस्', title: 'डाटाबेस खोल्नुहोस्',
description: description:
'तलको सूचीबाट खोल्नका लागि एक डायाग्राम चयन गर्नुहोस्।', 'तलको सूचीबाट खोल्नका लागि एक डायाग्राम चयन गर्नुहोस्।',
table_columns: { table_columns: {
@@ -327,6 +328,12 @@ export const ne: LanguageTranslation = {
}, },
cancel: 'रद्द गर्नुहोस्', cancel: 'रद्द गर्नुहोस्',
open: 'खोल्नुहोस्', open: 'खोल्नुहोस्',
diagram_actions: {
open: 'खोल्नुहोस्',
duplicate: 'डुप्लिकेट',
delete: 'मेटाउनुहोस्',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const pt_BR: LanguageTranslation = {
custom_types: 'Tipos Personalizados', custom_types: 'Tipos Personalizados',
}, },
menu: { menu: {
databases: { actions: {
databases: 'Bancos de Dados', actions: 'Ações',
new: 'Novo Diagrama', new: 'Novo...',
browse: 'Navegar...', browse: 'Navegar...',
save: 'Salvar', save: 'Salvar',
import: 'Importar Banco de Dados', import: 'Importar Banco de Dados',
export_sql: 'Exportar SQL', export_sql: 'Exportar SQL',
export_as: 'Exportar como', export_as: 'Exportar como',
delete_diagram: 'Excluir Diagrama', delete_diagram: 'Excluir',
}, },
edit: { edit: {
edit: 'Editar', edit: 'Editar',
@@ -75,10 +75,10 @@ export const pt_BR: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'Reordenar Diagrama', title: 'Organizar Diagrama Automaticamente',
description: description:
'Esta ação reorganizará todas as tabelas no diagrama. Deseja continuar?', 'Esta ação reorganizará todas as tabelas no diagrama. Deseja continuar?',
reorder: 'Reordenar', reorder: 'Organizar Automaticamente',
cancel: 'Cancelar', cancel: 'Cancelar',
}, },
@@ -249,6 +249,7 @@ export const pt_BR: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'Nenhum valor de enum definido',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -271,7 +272,7 @@ export const pt_BR: LanguageTranslation = {
show_all: 'Mostrar Tudo', show_all: 'Mostrar Tudo',
undo: 'Desfazer', undo: 'Desfazer',
redo: 'Refazer', redo: 'Refazer',
reorder_diagram: 'Reordenar Diagrama', reorder_diagram: 'Organizar Diagrama Automaticamente',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -316,7 +317,7 @@ export const pt_BR: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'Abrir Diagrama', title: 'Abrir Banco de Dados',
description: 'Selecione um diagrama para abrir da lista abaixo.', description: 'Selecione um diagrama para abrir da lista abaixo.',
table_columns: { table_columns: {
name: 'Nome', name: 'Nome',
@@ -326,6 +327,12 @@ export const pt_BR: LanguageTranslation = {
}, },
cancel: 'Cancelar', cancel: 'Cancelar',
open: 'Abrir', open: 'Abrir',
diagram_actions: {
open: 'Abrir',
duplicate: 'Duplicar',
delete: 'Excluir',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const ru: LanguageTranslation = {
custom_types: 'Пользовательские типы', custom_types: 'Пользовательские типы',
}, },
menu: { menu: {
databases: { actions: {
databases: 'Базы данных', actions: 'Действия',
new: 'Новая диаграмма', new: 'Новая...',
browse: 'Обзор...', browse: 'Обзор...',
save: 'Сохранить', save: 'Сохранить',
import: 'Импортировать базу данных', import: 'Импортировать базу данных',
export_sql: 'Экспорт SQL', export_sql: 'Экспорт SQL',
export_as: 'Экспортировать как', export_as: 'Экспортировать как',
delete_diagram: 'Удалить диаграмму', delete_diagram: 'Удалить',
}, },
edit: { edit: {
edit: 'Изменение', edit: 'Изменение',
@@ -73,10 +73,10 @@ export const ru: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'Переупорядочить диаграмму', title: 'Автоматическая расстановка диаграммы',
description: description:
'Это действие переставит все таблицы на диаграмме. Хотите продолжить?', 'Это действие переставит все таблицы на диаграмме. Хотите продолжить?',
reorder: 'Изменить порядок', reorder: 'Автоматическая расстановка',
cancel: 'Отменить', cancel: 'Отменить',
}, },
@@ -246,6 +246,7 @@ export const ru: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'Значения перечисления не определены',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -268,7 +269,7 @@ export const ru: LanguageTranslation = {
show_all: 'Показать все', show_all: 'Показать все',
undo: 'Отменить', undo: 'Отменить',
redo: 'Вернуть', redo: 'Вернуть',
reorder_diagram: 'Переупорядочить диаграмму', reorder_diagram: 'Автоматическая расстановка диаграммы',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -312,7 +313,7 @@ export const ru: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'Открыть диаграмму', title: 'Открыть базу данных',
description: description:
'Выберите диаграмму, которую нужно открыть, из списка ниже.', 'Выберите диаграмму, которую нужно открыть, из списка ниже.',
table_columns: { table_columns: {
@@ -323,6 +324,12 @@ export const ru: LanguageTranslation = {
}, },
cancel: 'Отмена', cancel: 'Отмена',
open: 'Открыть', open: 'Открыть',
diagram_actions: {
open: 'Открыть',
duplicate: 'Дублировать',
delete: 'Удалить',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const te: LanguageTranslation = {
custom_types: 'కస్టమ్ టైప్స్', custom_types: 'కస్టమ్ టైప్స్',
}, },
menu: { menu: {
databases: { actions: {
databases: 'డేటాబేస్లు', actions: 'చర్యలు',
new: 'కొత్త డైగ్రాం', new: 'కొత్తది...',
browse: 'బ్రాఉజ్ చేయండి...', browse: 'బ్రాఉజ్ చేయండి...',
save: 'సేవ్', save: 'సేవ్',
import: 'డేటాబేస్‌ను దిగుమతి చేసుకోండి', import: 'డేటాబేస్‌ను దిగుమతి చేసుకోండి',
export_sql: 'SQL ఎగుమతి', export_sql: 'SQL ఎగుమతి',
export_as: 'వగా ఎగుమతి చేయండి', export_as: 'వగా ఎగుమతి చేయండి',
delete_diagram: 'చిత్రాన్ని తొలగించండి', delete_diagram: 'తొలగించండి',
}, },
edit: { edit: {
edit: 'సవరించు', edit: 'సవరించు',
@@ -75,10 +75,10 @@ export const te: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'చిత్రాన్ని పునఃసరిచేయండి', title: 'చిత్రాన్ని స్వయంచాలకంగా అమర్చండి',
description: description:
'ఈ చర్య చిత్రంలోని అన్ని పట్టికలను పునఃస్థాపిస్తుంది. మీరు కొనసాగించాలనుకుంటున్నారా?', 'ఈ చర్య చిత్రంలోని అన్ని పట్టికలను పునఃస్థాపిస్తుంది. మీరు కొనసాగించాలనుకుంటున్నారా?',
reorder: 'పునఃసరిచేయండి', reorder: 'స్వయంచాలకంగా అమర్చండి',
cancel: 'రద్దు', cancel: 'రద్దు',
}, },
@@ -250,6 +250,7 @@ export const te: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'ఏ enum విలువలు నిర్వచించబడలేదు',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -272,7 +273,7 @@ export const te: LanguageTranslation = {
show_all: 'అన్ని చూపించు', show_all: 'అన్ని చూపించు',
undo: 'తిరిగి చేయు', undo: 'తిరిగి చేయు',
redo: 'మరలా చేయు', redo: 'మరలా చేయు',
reorder_diagram: 'చిత్రాన్ని పునఃసరిచేయండి', reorder_diagram: 'చిత్రాన్ని స్వయంచాలకంగా అమర్చండి',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -317,7 +318,7 @@ export const te: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'చిత్రం తెరవండి', title: 'డేటాబేస్ తెరవండి',
description: 'కింద ఉన్న జాబితా నుండి చిత్రాన్ని ఎంచుకోండి.', description: 'కింద ఉన్న జాబితా నుండి చిత్రాన్ని ఎంచుకోండి.',
table_columns: { table_columns: {
name: 'పేరు', name: 'పేరు',
@@ -327,6 +328,12 @@ export const te: LanguageTranslation = {
}, },
cancel: 'రద్దు', cancel: 'రద్దు',
open: 'తెరవు', open: 'తెరవు',
diagram_actions: {
open: 'తెరవు',
duplicate: 'నకలు',
delete: 'తొలగించు',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const tr: LanguageTranslation = {
custom_types: 'Özel Tipler', custom_types: 'Özel Tipler',
}, },
menu: { menu: {
databases: { actions: {
databases: 'Veritabanları', actions: 'Eylemler',
new: 'Yeni Diyagram', new: 'Yeni...',
browse: 'Gözat...', browse: 'Gözat...',
save: 'Kaydet', save: 'Kaydet',
import: 'Veritabanı İçe Aktar', import: 'Veritabanı İçe Aktar',
export_sql: 'SQL Olarak Dışa Aktar', export_sql: 'SQL Olarak Dışa Aktar',
export_as: 'Olarak Dışa Aktar', export_as: 'Olarak Dışa Aktar',
delete_diagram: 'Diyagramı Sil', delete_diagram: 'Sil',
}, },
edit: { edit: {
edit: 'Düzenle', edit: 'Düzenle',
@@ -75,10 +75,10 @@ export const tr: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'Diyagramı Yeniden Sırala', title: 'Diyagramı Otomatik Düzenle',
description: description:
'Bu işlem tüm tabloları yeniden düzenleyecektir. Devam etmek istiyor musunuz?', 'Bu işlem tüm tabloları yeniden düzenleyecektir. Devam etmek istiyor musunuz?',
reorder: 'Yeniden Sırala', reorder: 'Otomatik Düzenle',
cancel: 'İptal', cancel: 'İptal',
}, },
@@ -249,6 +249,7 @@ export const tr: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'Tanımlanmış enum değeri yok',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -270,7 +271,7 @@ export const tr: LanguageTranslation = {
show_all: 'Hepsini Gör', show_all: 'Hepsini Gör',
undo: 'Geri Al', undo: 'Geri Al',
redo: 'Yinele', redo: 'Yinele',
reorder_diagram: 'Diyagramı Yeniden Sırala', reorder_diagram: 'Diyagramı Otomatik Düzenle',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -312,7 +313,7 @@ export const tr: LanguageTranslation = {
import: 'İçe Aktar', import: 'İçe Aktar',
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'Diyagramı Aç', title: 'Veritabanı Aç',
description: 'Aşağıdaki listeden açmak için bir diyagram seçin.', description: 'Aşağıdaki listeden açmak için bir diyagram seçin.',
table_columns: { table_columns: {
name: 'Ad', name: 'Ad',
@@ -322,6 +323,12 @@ export const tr: LanguageTranslation = {
}, },
cancel: 'İptal', cancel: 'İptal',
open: 'Aç', open: 'Aç',
diagram_actions: {
open: 'Aç',
duplicate: 'Kopyala',
delete: 'Sil',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const uk: LanguageTranslation = {
custom_types: 'Користувацькі типи', custom_types: 'Користувацькі типи',
}, },
menu: { menu: {
databases: { actions: {
databases: 'Бази даних', actions: 'Дії',
new: 'Нова діаграма', new: 'Нова...',
browse: 'Огляд...', browse: 'Огляд...',
save: 'Зберегти', save: 'Зберегти',
import: 'Імпорт бази даних', import: 'Імпорт бази даних',
export_sql: 'Експорт SQL', export_sql: 'Експорт SQL',
export_as: 'Експортувати як', export_as: 'Експортувати як',
delete_diagram: 'Видалити діаграму', delete_diagram: 'Видалити',
}, },
edit: { edit: {
edit: 'Редагувати', edit: 'Редагувати',
@@ -73,10 +73,10 @@ export const uk: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'Перевпорядкувати діаграму', title: 'Автоматичне розміщення діаграми',
description: description:
'Ця дія перевпорядкує всі таблиці на діаграмі. Хочете продовжити?', 'Ця дія перевпорядкує всі таблиці на діаграмі. Хочете продовжити?',
reorder: 'Перевпорядкувати', reorder: 'Автоматичне розміщення',
cancel: 'Скасувати', cancel: 'Скасувати',
}, },
@@ -247,6 +247,7 @@ export const uk: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'Значення переліку не визначені',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -269,7 +270,7 @@ export const uk: LanguageTranslation = {
show_all: 'Показати все', show_all: 'Показати все',
undo: 'Скасувати', undo: 'Скасувати',
redo: 'Повторити', redo: 'Повторити',
reorder_diagram: 'Перевпорядкувати діаграму', reorder_diagram: 'Автоматичне розміщення діаграми',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -313,7 +314,7 @@ export const uk: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'Відкрити діаграму', title: 'Відкрити базу даних',
description: description:
'Виберіть діаграму, яку потрібно відкрити, зі списку нижче.', 'Виберіть діаграму, яку потрібно відкрити, зі списку нижче.',
table_columns: { table_columns: {
@@ -324,6 +325,12 @@ export const uk: LanguageTranslation = {
}, },
cancel: 'Скасувати', cancel: 'Скасувати',
open: 'Відкрити', open: 'Відкрити',
diagram_actions: {
open: 'Відкрити',
duplicate: 'Дублювати',
delete: 'Видалити',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const vi: LanguageTranslation = {
custom_types: 'Kiểu tùy chỉnh', custom_types: 'Kiểu tùy chỉnh',
}, },
menu: { menu: {
databases: { actions: {
databases: 'Cơ sở dữ liệu', actions: 'Hành động',
new: 'Sơ đồ mới', new: 'Mới...',
browse: 'Duyệt...', browse: 'Duyệt...',
save: 'Lưu', save: 'Lưu',
import: 'Nhập cơ sở dữ liệu', import: 'Nhập cơ sở dữ liệu',
export_sql: 'Xuất SQL', export_sql: 'Xuất SQL',
export_as: 'Xuất thành', export_as: 'Xuất thành',
delete_diagram: 'Xóa sơ đồ', delete_diagram: 'Xóa',
}, },
edit: { edit: {
edit: 'Sửa', edit: 'Sửa',
@@ -74,10 +74,10 @@ export const vi: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: 'Sắp xếp lại sơ đồ', title: 'Tự động sắp xếp sơ đồ',
description: description:
'Hành động này sẽ sắp xếp lại tất cả các bảng trong sơ đồ. Bạn có muốn tiếp tục không?', 'Hành động này sẽ sắp xếp lại tất cả các bảng trong sơ đồ. Bạn có muốn tiếp tục không?',
reorder: 'Sắp xếp', reorder: 'Tự động sắp xếp',
cancel: 'Hủy', cancel: 'Hủy',
}, },
@@ -248,6 +248,7 @@ export const vi: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: 'Không có giá trị enum được định nghĩa',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -270,7 +271,7 @@ export const vi: LanguageTranslation = {
show_all: 'Hiển thị tất cả', show_all: 'Hiển thị tất cả',
undo: 'Hoàn tác', undo: 'Hoàn tác',
redo: 'Làm lại', redo: 'Làm lại',
reorder_diagram: 'Sắp xếp lại sơ đồ', reorder_diagram: 'Tự động sắp xếp sơ đồ',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -314,7 +315,7 @@ export const vi: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: 'Mở sơ đồ', title: 'Mở cơ sở dữ liệu',
description: 'Chọn sơ đồ để mở từ danh sách bên dưới.', description: 'Chọn sơ đồ để mở từ danh sách bên dưới.',
table_columns: { table_columns: {
name: 'Tên', name: 'Tên',
@@ -324,6 +325,12 @@ export const vi: LanguageTranslation = {
}, },
cancel: 'Hủy', cancel: 'Hủy',
open: 'Mở', open: 'Mở',
diagram_actions: {
open: 'Mở',
duplicate: 'Nhân bản',
delete: 'Xóa',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const zh_CN: LanguageTranslation = {
custom_types: '自定义类型', custom_types: '自定义类型',
}, },
menu: { menu: {
databases: { actions: {
databases: '数据库', actions: '操作',
new: '新建关系图', new: '新建...',
browse: '浏览...', browse: '浏览...',
save: '保存', save: '保存',
import: '导入数据库', import: '导入数据库',
export_sql: '导出 SQL 语句', export_sql: '导出 SQL 语句',
export_as: '导出为', export_as: '导出为',
delete_diagram: '删除关系图', delete_diagram: '删除',
}, },
edit: { edit: {
edit: '编辑', edit: '编辑',
@@ -72,9 +72,9 @@ export const zh_CN: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: '重新排列关系图', title: '自动排列关系图',
description: '此操作将重新排列关系图中的所有表。是否要继续?', description: '此操作将重新排列关系图中的所有表。是否要继续?',
reorder: '重新排列', reorder: '自动排列',
cancel: '取消', cancel: '取消',
}, },
@@ -245,6 +245,7 @@ export const zh_CN: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: '没有定义枚举值',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -267,7 +268,7 @@ export const zh_CN: LanguageTranslation = {
show_all: '展示全部', show_all: '展示全部',
undo: '撤销', undo: '撤销',
redo: '重做', redo: '重做',
reorder_diagram: '重新排列关系图', reorder_diagram: '自动排列关系图',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -311,7 +312,7 @@ export const zh_CN: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: '打开关系图', title: '打开数据库',
description: '从下面的列表中选择一个图表打开。', description: '从下面的列表中选择一个图表打开。',
table_columns: { table_columns: {
name: '名称', name: '名称',
@@ -321,6 +322,12 @@ export const zh_CN: LanguageTranslation = {
}, },
cancel: '取消', cancel: '取消',
open: '打开', open: '打开',
diagram_actions: {
open: '打开',
duplicate: '复制',
delete: '删除',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -12,15 +12,15 @@ export const zh_TW: LanguageTranslation = {
custom_types: '自定義類型', custom_types: '自定義類型',
}, },
menu: { menu: {
databases: { actions: {
databases: '資料庫', actions: '操作',
new: '新增圖表', new: '新增...',
browse: '瀏覽...', browse: '瀏覽...',
save: '儲存', save: '儲存',
import: '匯入資料庫', import: '匯入資料庫',
export_sql: '匯出 SQL', export_sql: '匯出 SQL',
export_as: '匯出為特定格式', export_as: '匯出為特定格式',
delete_diagram: '刪除圖表', delete_diagram: '刪除',
}, },
edit: { edit: {
edit: '編輯', edit: '編輯',
@@ -72,9 +72,9 @@ export const zh_TW: LanguageTranslation = {
}, },
reorder_diagram_alert: { reorder_diagram_alert: {
title: '重新排列圖表', title: '自動排列圖表',
description: '此操作將重新排列圖表中的所有表格。是否繼續?', description: '此操作將重新排列圖表中的所有表格。是否繼續?',
reorder: '重新排列', reorder: '自動排列',
cancel: '取消', cancel: '取消',
}, },
@@ -245,6 +245,7 @@ export const zh_TW: LanguageTranslation = {
enum_values: 'Enum Values', enum_values: 'Enum Values',
composite_fields: 'Fields', composite_fields: 'Fields',
no_fields: 'No fields defined', no_fields: 'No fields defined',
no_values: '沒有定義列舉值',
field_name_placeholder: 'Field name', field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type', field_type_placeholder: 'Select type',
add_field: 'Add Field', add_field: 'Add Field',
@@ -267,7 +268,7 @@ export const zh_TW: LanguageTranslation = {
show_all: '顯示全部', show_all: '顯示全部',
undo: '復原', undo: '復原',
redo: '重做', redo: '重做',
reorder_diagram: '重新排列圖表', reorder_diagram: '自動排列圖表',
// TODO: Translate // TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip: custom_type_highlight_tooltip:
@@ -310,7 +311,7 @@ export const zh_TW: LanguageTranslation = {
}, },
open_diagram_dialog: { open_diagram_dialog: {
title: '開啟圖表', title: '開啟資料庫',
description: '請從以下列表中選擇一個圖表。', description: '請從以下列表中選擇一個圖表。',
table_columns: { table_columns: {
name: '名稱', name: '名稱',
@@ -320,6 +321,12 @@ export const zh_TW: LanguageTranslation = {
}, },
cancel: '取消', cancel: '取消',
open: '開啟', open: '開啟',
diagram_actions: {
open: '開啟',
duplicate: '複製',
delete: '刪除',
},
}, },
export_sql_dialog: { export_sql_dialog: {

View File

@@ -0,0 +1,21 @@
import type { DBCustomType, DBCustomTypeKind } from '@/lib/domain';
import { schemaNameToDomainSchemaName } from '@/lib/domain';
import type { DBCustomTypeInfo } from '../metadata-types/custom-type-info';
import { generateId } from '@/lib/utils';
export const createCustomTypesFromMetadata = ({
customTypes,
}: {
customTypes: DBCustomTypeInfo[];
}): DBCustomType[] => {
return customTypes.map((customType) => {
return {
id: generateId(),
schema: schemaNameToDomainSchemaName(customType.schema),
name: customType.type,
kind: customType.kind as DBCustomTypeKind,
values: customType.values,
fields: customType.fields,
};
});
};

View File

@@ -0,0 +1,351 @@
import { generateId } from '@/lib/utils';
import type { AST } from 'node-sql-parser';
import type { DBDependency, DBTable } from '@/lib/domain';
import { DatabaseType, schemaNameToDomainSchemaName } from '@/lib/domain';
import type { ViewInfo } from '../metadata-types/view-info';
import { decodeViewDefinition } from './tables';
const astDatabaseTypes: Record<DatabaseType, string> = {
[DatabaseType.POSTGRESQL]: 'postgresql',
[DatabaseType.MYSQL]: 'postgresql',
[DatabaseType.MARIADB]: 'postgresql',
[DatabaseType.GENERIC]: 'postgresql',
[DatabaseType.SQLITE]: 'postgresql',
[DatabaseType.SQL_SERVER]: 'postgresql',
[DatabaseType.CLICKHOUSE]: 'postgresql',
[DatabaseType.COCKROACHDB]: 'postgresql',
[DatabaseType.ORACLE]: 'postgresql',
};
export const createDependenciesFromMetadata = async ({
views,
tables,
databaseType,
}: {
views: ViewInfo[];
tables: DBTable[];
databaseType: DatabaseType;
}): Promise<DBDependency[]> => {
if (!views || views.length === 0) {
return [];
}
const { Parser } = await import('node-sql-parser');
const parser = new Parser();
const dependencies = views
.flatMap((view) => {
const viewSchema = schemaNameToDomainSchemaName(view.schema);
const viewTable = tables.find(
(table) =>
table.name === view.view_name && viewSchema === table.schema
);
if (!viewTable) {
console.warn(
`Source table for view ${view.view_name} not found (schema: ${viewSchema})`
);
return []; // Skip this view and proceed to the next
}
if (view.view_definition) {
try {
const decodedViewDefinition = decodeViewDefinition(
databaseType,
view.view_definition
);
let modifiedViewDefinition = '';
if (
databaseType === DatabaseType.MYSQL ||
databaseType === DatabaseType.MARIADB
) {
modifiedViewDefinition = preprocessViewDefinitionMySQL(
decodedViewDefinition
);
} else if (databaseType === DatabaseType.SQL_SERVER) {
modifiedViewDefinition =
preprocessViewDefinitionSQLServer(
decodedViewDefinition
);
} else {
modifiedViewDefinition = preprocessViewDefinition(
decodedViewDefinition
);
}
// Parse using the appropriate dialect
const ast = parser.astify(modifiedViewDefinition, {
database: astDatabaseTypes[databaseType],
type: 'select', // Parsing a SELECT statement
});
let relatedTables = extractTablesFromAST(ast);
// Filter out duplicate tables without schema
relatedTables = filterDuplicateTables(relatedTables);
return relatedTables.map((relTable) => {
const relSchema = relTable.schema || view.schema; // Use view's schema if relSchema is undefined
const relTableName = relTable.tableName;
const table = tables.find(
(table) =>
table.name === relTableName &&
(table.schema || '') === relSchema
);
if (table) {
const dependency: DBDependency = {
id: generateId(),
schema: view.schema,
tableId: table.id, // related table
dependentSchema: table.schema,
dependentTableId: viewTable.id, // dependent view
createdAt: Date.now(),
};
return dependency;
} else {
console.warn(
`Dependent table ${relSchema}.${relTableName} not found for view ${view.schema}.${view.view_name}`
);
return null;
}
});
} catch (error) {
console.error(
`Error parsing view ${view.schema}.${view.view_name}:`,
error
);
return [];
}
} else {
console.warn(
`View definition missing for ${view.schema}.${view.view_name}`
);
return [];
}
})
.filter((dependency) => dependency !== null);
return dependencies;
};
// Add this new function to filter out duplicate tables
function filterDuplicateTables(
tables: { schema?: string; tableName: string }[]
): { schema?: string; tableName: string }[] {
const tableMap = new Map<string, { schema?: string; tableName: string }>();
for (const table of tables) {
const key = table.tableName;
const existingTable = tableMap.get(key);
if (!existingTable || (table.schema && !existingTable.schema)) {
tableMap.set(key, table);
}
}
return Array.from(tableMap.values());
}
// Preprocess the view_definition to remove schema from CREATE VIEW
function preprocessViewDefinition(viewDefinition: string): string {
if (!viewDefinition) {
return '';
}
// Remove leading and trailing whitespace
viewDefinition = viewDefinition.replace(/\s+/g, ' ').trim();
// Replace escaped double quotes with regular ones
viewDefinition = viewDefinition.replace(/\\"/g, '"');
// Replace 'CREATE MATERIALIZED VIEW' with 'CREATE VIEW'
viewDefinition = viewDefinition.replace(
/CREATE\s+MATERIALIZED\s+VIEW/i,
'CREATE VIEW'
);
// Regular expression to match 'CREATE VIEW [schema.]view_name [ (column definitions) ] AS'
// This regex captures the view name and skips any content between the view name and 'AS'
const regex =
/CREATE\s+VIEW\s+(?:(?:`[^`]+`|"[^"]+"|\w+)\.)?(?:`([^`]+)`|"([^"]+)"|(\w+))[\s\S]*?\bAS\b\s+/i;
const match = viewDefinition.match(regex);
let modifiedDefinition: string;
if (match) {
const viewName = match[1] || match[2] || match[3];
// Extract the SQL after the 'AS' keyword
const restOfDefinition = viewDefinition.substring(
match.index! + match[0].length
);
// Replace double-quoted identifiers with unquoted ones
let modifiedSQL = restOfDefinition.replace(/"(\w+)"/g, '$1');
// Replace '::' type casts with 'CAST' expressions
modifiedSQL = modifiedSQL.replace(
/\(([^()]+)\)::(\w+)/g,
'CAST($1 AS $2)'
);
// Remove ClickHouse-specific syntax that may still be present
// For example, remove SETTINGS clauses inside the SELECT statement
modifiedSQL = modifiedSQL.replace(/\bSETTINGS\b[\s\S]*$/i, '');
modifiedDefinition = `CREATE VIEW ${viewName} AS ${modifiedSQL}`;
} else {
console.warn('Could not preprocess view definition:', viewDefinition);
modifiedDefinition = viewDefinition;
}
return modifiedDefinition;
}
// Preprocess the view_definition for SQL Server
function preprocessViewDefinitionSQLServer(viewDefinition: string): string {
if (!viewDefinition) {
return '';
}
// Remove BOM if present
viewDefinition = viewDefinition.replace(/^\uFEFF/, '');
// Normalize whitespace
viewDefinition = viewDefinition.replace(/\s+/g, ' ').trim();
// Remove square brackets and replace with double quotes
viewDefinition = viewDefinition.replace(/\[([^\]]+)\]/g, '"$1"');
// Remove database names from fully qualified identifiers
viewDefinition = viewDefinition.replace(
/"([a-zA-Z0-9_]+)"\."([a-zA-Z0-9_]+)"\."([a-zA-Z0-9_]+)"/g,
'"$2"."$3"'
);
// Replace SQL Server functions with PostgreSQL equivalents
viewDefinition = viewDefinition.replace(/\bGETDATE\(\)/gi, 'NOW()');
viewDefinition = viewDefinition.replace(/\bISNULL\(/gi, 'COALESCE(');
// Replace 'TOP N' with 'LIMIT N' at the end of the query
const topMatch = viewDefinition.match(/SELECT\s+TOP\s+(\d+)/i);
if (topMatch) {
const topN = topMatch[1];
viewDefinition = viewDefinition.replace(
/SELECT\s+TOP\s+\d+/i,
'SELECT'
);
viewDefinition = viewDefinition.replace(/;+\s*$/, ''); // Remove semicolons at the end
viewDefinition += ` LIMIT ${topN}`;
}
viewDefinition = viewDefinition.replace(/\n/g, ''); // Remove newlines
// Adjust CREATE VIEW syntax
const regex =
/CREATE\s+VIEW\s+(?:"?([^".\s]+)"?\.)?"?([^".\s]+)"?\s+AS\s+/i;
const match = viewDefinition.match(regex);
let modifiedDefinition: string;
if (match) {
const viewName = match[2];
const modifiedSQL = viewDefinition.substring(
match.index! + match[0].length
);
// Remove semicolons at the end
const finalSQL = modifiedSQL.replace(/;+\s*$/, '');
modifiedDefinition = `CREATE VIEW "${viewName}" AS ${finalSQL}`;
} else {
console.warn('Could not preprocess view definition:', viewDefinition);
modifiedDefinition = viewDefinition;
}
return modifiedDefinition;
}
// Preprocess the view_definition to remove schema from CREATE VIEW
function preprocessViewDefinitionMySQL(viewDefinition: string): string {
if (!viewDefinition) {
return '';
}
// Remove any trailing semicolons
viewDefinition = viewDefinition.replace(/;\s*$/, '');
// Remove backticks from identifiers
viewDefinition = viewDefinition.replace(/`/g, '');
// Remove unnecessary parentheses around joins and ON clauses
viewDefinition = removeRedundantParentheses(viewDefinition);
return viewDefinition;
}
function removeRedundantParentheses(sql: string): string {
// Regular expressions to match unnecessary parentheses
const patterns = [
/\(\s*(JOIN\s+[^()]+?)\s*\)/gi,
/\(\s*(ON\s+[^()]+?)\s*\)/gi,
// Additional patterns if necessary
];
let prevSql;
do {
prevSql = sql;
patterns.forEach((pattern) => {
sql = sql.replace(pattern, '$1');
});
} while (sql !== prevSql);
return sql;
}
function extractTablesFromAST(
ast: AST | AST[]
): { schema?: string; tableName: string }[] {
const tablesMap = new Map<string, { schema: string; tableName: string }>();
const visitedNodes = new Set();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function traverse(node: any) {
if (!node || visitedNodes.has(node)) return;
visitedNodes.add(node);
if (Array.isArray(node)) {
node.forEach(traverse);
} else if (typeof node === 'object') {
// Check if node represents a table
if (
Object.hasOwnProperty.call(node, 'table') &&
typeof node.table === 'string'
) {
let schema = node.db || node.schema;
const tableName = node.table;
if (tableName) {
// Assign default schema if undefined
schema = schemaNameToDomainSchemaName(schema) || '';
const key = `${schema}.${tableName}`;
if (!tablesMap.has(key)) {
tablesMap.set(key, { schema, tableName });
}
}
}
// Recursively traverse all properties
for (const key in node) {
if (Object.hasOwnProperty.call(node, key)) {
traverse(node[key]);
}
}
}
}
traverse(ast);
return Array.from(tablesMap.values());
}

View File

@@ -0,0 +1,64 @@
import type { DBField } from '@/lib/domain';
import type { ColumnInfo } from '../metadata-types/column-info';
import type { AggregatedIndexInfo } from '../metadata-types/index-info';
import type { PrimaryKeyInfo } from '../metadata-types/primary-key-info';
import type { TableInfo } from '../metadata-types/table-info';
import { generateId } from '@/lib/utils';
export const createFieldsFromMetadata = ({
tableColumns,
tablePrimaryKeys,
aggregatedIndexes,
}: {
tableColumns: ColumnInfo[];
tableSchema?: string;
tableInfo: TableInfo;
tablePrimaryKeys: PrimaryKeyInfo[];
aggregatedIndexes: AggregatedIndexInfo[];
}) => {
const uniqueColumns = tableColumns.reduce((acc, col) => {
if (!acc.has(col.name)) {
acc.set(col.name, col);
}
return acc;
}, new Map<string, ColumnInfo>());
const sortedColumns = Array.from(uniqueColumns.values()).sort(
(a, b) => a.ordinal_position - b.ordinal_position
);
const tablePrimaryKeysColumns = tablePrimaryKeys.map((pk) =>
pk.column.trim()
);
return sortedColumns.map(
(col: ColumnInfo): DBField => ({
id: generateId(),
name: col.name,
type: {
id: col.type.split(' ').join('_').toLowerCase(),
name: col.type.toLowerCase(),
},
primaryKey: tablePrimaryKeysColumns.includes(col.name),
unique: Object.values(aggregatedIndexes).some(
(idx) =>
idx.unique &&
idx.columns.length === 1 &&
idx.columns[0].name === col.name
),
nullable: Boolean(col.nullable),
...(col.character_maximum_length &&
col.character_maximum_length !== 'null'
? { characterMaximumLength: col.character_maximum_length }
: {}),
...(col.precision?.precision
? { precision: col.precision.precision }
: {}),
...(col.precision?.scale ? { scale: col.precision.scale } : {}),
...(col.default ? { default: col.default } : {}),
...(col.collation ? { collation: col.collation } : {}),
createdAt: Date.now(),
comments: col.comment ? col.comment : undefined,
})
);
};

View File

@@ -0,0 +1,82 @@
import type { DatabaseEdition, Diagram } from '@/lib/domain';
import { adjustTablePositions, DatabaseType } from '@/lib/domain';
import { generateDiagramId } from '@/lib/utils';
import type { DatabaseMetadata } from '../metadata-types/database-metadata';
import { createCustomTypesFromMetadata } from './custom-types';
import { createRelationshipsFromMetadata } from './relationships';
import { createTablesFromMetadata } from './tables';
import { createDependenciesFromMetadata } from './dependencies';
export const loadFromDatabaseMetadata = async ({
databaseType,
databaseMetadata,
diagramNumber,
databaseEdition,
}: {
databaseType: DatabaseType;
databaseMetadata: DatabaseMetadata;
diagramNumber?: number;
databaseEdition?: DatabaseEdition;
}): Promise<Diagram> => {
const {
fk_info: foreignKeys,
views: views,
custom_types: customTypes,
} = databaseMetadata;
const tables = createTablesFromMetadata({
databaseMetadata,
databaseType,
});
const relationships = createRelationshipsFromMetadata({
foreignKeys,
tables,
});
const dependencies = await createDependenciesFromMetadata({
views,
tables,
databaseType,
});
const dbCustomTypes = customTypes
? createCustomTypesFromMetadata({
customTypes,
})
: [];
const adjustedTables = adjustTablePositions({
tables,
relationships,
mode: 'perSchema',
});
const sortedTables = adjustedTables.sort((a, b) => {
if (a.isView === b.isView) {
// Both are either tables or views, so sort alphabetically by name
return a.name.localeCompare(b.name);
}
// If one is a view and the other is not, put tables first
return a.isView ? 1 : -1;
});
const diagram: Diagram = {
id: generateDiagramId(),
name: databaseMetadata.database_name
? `${databaseMetadata.database_name}-db`
: diagramNumber
? `Diagram ${diagramNumber}`
: 'New Diagram',
databaseType: databaseType ?? DatabaseType.GENERIC,
databaseEdition,
tables: sortedTables,
relationships,
dependencies,
customTypes: dbCustomTypes,
createdAt: new Date(),
updatedAt: new Date(),
};
return diagram;
};

View File

@@ -0,0 +1,24 @@
import type { DBField, DBIndex, IndexType } from '@/lib/domain';
import type { AggregatedIndexInfo } from '../metadata-types/index-info';
import { generateId } from '@/lib/utils';
export const createIndexesFromMetadata = ({
aggregatedIndexes,
fields,
}: {
aggregatedIndexes: AggregatedIndexInfo[];
fields: DBField[];
}): DBIndex[] =>
aggregatedIndexes.map(
(idx): DBIndex => ({
id: generateId(),
name: idx.name,
unique: Boolean(idx.unique),
fieldIds: idx.columns
.sort((a, b) => a.position - b.position)
.map((c) => fields.find((f) => f.name === c.name)?.id)
.filter((id): id is string => id !== undefined),
createdAt: Date.now(),
type: idx.index_type?.toLowerCase() as IndexType,
})
);

View File

@@ -0,0 +1,85 @@
import type {
Cardinality,
DBField,
DBRelationship,
DBTable,
} from '@/lib/domain';
import { schemaNameToDomainSchemaName } from '@/lib/domain';
import type { ForeignKeyInfo } from '../metadata-types/foreign-key-info';
import { generateId } from '@/lib/utils';
const determineCardinality = (
field: DBField,
isTablePKComplex: boolean
): Cardinality => {
return field.unique || (field.primaryKey && !isTablePKComplex)
? 'one'
: 'many';
};
export const createRelationshipsFromMetadata = ({
foreignKeys,
tables,
}: {
foreignKeys: ForeignKeyInfo[];
tables: DBTable[];
}): DBRelationship[] => {
return foreignKeys
.map((fk: ForeignKeyInfo): DBRelationship | null => {
const schema = schemaNameToDomainSchemaName(fk.schema);
const sourceTable = tables.find(
(table) => table.name === fk.table && table.schema === schema
);
const targetSchema = schemaNameToDomainSchemaName(
fk.reference_schema
);
const targetTable = tables.find(
(table) =>
table.name === fk.reference_table &&
table.schema === targetSchema
);
const sourceField = sourceTable?.fields.find(
(field) => field.name === fk.column
);
const targetField = targetTable?.fields.find(
(field) => field.name === fk.reference_column
);
const isSourceTablePKComplex =
(sourceTable?.fields.filter((field) => field.primaryKey) ?? [])
.length > 1;
const isTargetTablePKComplex =
(targetTable?.fields.filter((field) => field.primaryKey) ?? [])
.length > 1;
if (sourceTable && targetTable && sourceField && targetField) {
const sourceCardinality = determineCardinality(
sourceField,
isSourceTablePKComplex
);
const targetCardinality = determineCardinality(
targetField,
isTargetTablePKComplex
);
return {
id: generateId(),
name: fk.foreign_key_name,
sourceSchema: schema,
targetSchema: targetSchema,
sourceTableId: sourceTable.id,
targetTableId: targetTable.id,
sourceFieldId: sourceField.id,
targetFieldId: targetField.id,
sourceCardinality,
targetCardinality,
createdAt: Date.now(),
};
}
return null;
})
.filter((rel) => rel !== null) as DBRelationship[];
};

View File

@@ -0,0 +1,228 @@
import type { DBIndex, DBTable } from '@/lib/domain';
import {
DatabaseType,
generateTableKey,
schemaNameToDomainSchemaName,
} from '@/lib/domain';
import type { DatabaseMetadata } from '../metadata-types/database-metadata';
import type { TableInfo } from '../metadata-types/table-info';
import { createAggregatedIndexes } from '../metadata-types/index-info';
import {
decodeBase64ToUtf16LE,
decodeBase64ToUtf8,
generateId,
} from '@/lib/utils';
import {
defaultTableColor,
materializedViewColor,
viewColor,
} from '@/lib/colors';
import { createFieldsFromMetadata } from './fields';
import { createIndexesFromMetadata } from './indexes';
export const decodeViewDefinition = (
databaseType: DatabaseType,
viewDefinition?: string
): string => {
if (!viewDefinition) {
return '';
}
let decodedViewDefinition: string;
if (databaseType === DatabaseType.SQL_SERVER) {
decodedViewDefinition = decodeBase64ToUtf16LE(viewDefinition);
} else {
decodedViewDefinition = decodeBase64ToUtf8(viewDefinition);
}
return decodedViewDefinition;
};
export const createTablesFromMetadata = ({
databaseMetadata,
databaseType,
}: {
databaseMetadata: DatabaseMetadata;
databaseType: DatabaseType;
}): DBTable[] => {
const {
tables: tableInfos,
pk_info: primaryKeys,
columns,
indexes,
views: views,
} = databaseMetadata;
// Pre-compute view names for faster lookup if there are views
const viewNamesSet = new Set<string>();
const materializedViewNamesSet = new Set<string>();
if (views && views.length > 0) {
views.forEach((view) => {
const key = generateTableKey({
schemaName: view.schema,
tableName: view.view_name,
});
viewNamesSet.add(key);
if (
view.view_definition &&
decodeViewDefinition(databaseType, view.view_definition)
.toLowerCase()
.includes('materialized')
) {
materializedViewNamesSet.add(key);
}
});
}
// Pre-compute lookup maps for better performance
const columnsByTable = new Map<string, (typeof columns)[0][]>();
const indexesByTable = new Map<string, (typeof indexes)[0][]>();
const primaryKeysByTable = new Map<string, (typeof primaryKeys)[0][]>();
// Group columns by table
columns.forEach((col) => {
const key = generateTableKey({
schemaName: col.schema,
tableName: col.table,
});
if (!columnsByTable.has(key)) {
columnsByTable.set(key, []);
}
columnsByTable.get(key)!.push(col);
});
// Group indexes by table
indexes.forEach((idx) => {
const key = generateTableKey({
schemaName: idx.schema,
tableName: idx.table,
});
if (!indexesByTable.has(key)) {
indexesByTable.set(key, []);
}
indexesByTable.get(key)!.push(idx);
});
// Group primary keys by table
primaryKeys.forEach((pk) => {
const key = generateTableKey({
schemaName: pk.schema,
tableName: pk.table,
});
if (!primaryKeysByTable.has(key)) {
primaryKeysByTable.set(key, []);
}
primaryKeysByTable.get(key)!.push(pk);
});
const result = tableInfos.map((tableInfo: TableInfo) => {
const tableSchema = schemaNameToDomainSchemaName(tableInfo.schema);
const tableKey = generateTableKey({
schemaName: tableInfo.schema,
tableName: tableInfo.table,
});
// Use pre-computed lookups instead of filtering entire arrays
const tableIndexes = indexesByTable.get(tableKey) || [];
const tablePrimaryKeys = primaryKeysByTable.get(tableKey) || [];
const tableColumns = columnsByTable.get(tableKey) || [];
// Aggregate indexes with multiple columns
const aggregatedIndexes = createAggregatedIndexes({
tableInfo,
tableSchema,
tableIndexes,
});
const fields = createFieldsFromMetadata({
aggregatedIndexes,
tableColumns,
tablePrimaryKeys,
tableInfo,
tableSchema,
});
// Check for composite primary key and find matching index name
const primaryKeyFields = fields.filter((f) => f.primaryKey);
let pkMatchingIndexName: string | undefined;
let pkIndex: DBIndex | undefined;
if (primaryKeyFields.length >= 1) {
// We have a composite primary key, look for an index that matches all PK columns
const pkFieldNames = primaryKeyFields.map((f) => f.name).sort();
// Find an index that matches the primary key columns exactly
const matchingIndex = aggregatedIndexes.find((index) => {
const indexColumnNames = index.columns
.map((c) => c.name)
.sort();
return (
indexColumnNames.length === pkFieldNames.length &&
indexColumnNames.every((col, i) => col === pkFieldNames[i])
);
});
if (matchingIndex) {
pkMatchingIndexName = matchingIndex.name;
// Create a special PK index
pkIndex = {
id: generateId(),
name: matchingIndex.name,
unique: true,
fieldIds: primaryKeyFields.map((f) => f.id),
createdAt: Date.now(),
isPrimaryKey: true,
};
}
}
// Filter out the index that matches the composite PK (to avoid duplication)
const filteredAggregatedIndexes = pkMatchingIndexName
? aggregatedIndexes.filter(
(idx) => idx.name !== pkMatchingIndexName
)
: aggregatedIndexes;
const dbIndexes = createIndexesFromMetadata({
aggregatedIndexes: filteredAggregatedIndexes,
fields,
});
// Add the PK index if it exists
if (pkIndex) {
dbIndexes.push(pkIndex);
}
// Determine if the current table is a view by checking against pre-computed sets
const viewKey = generateTableKey({
schemaName: tableSchema,
tableName: tableInfo.table,
});
const isView = viewNamesSet.has(viewKey);
const isMaterializedView = materializedViewNamesSet.has(viewKey);
// Initial random positions; these will be adjusted later
return {
id: generateId(),
name: tableInfo.table,
schema: tableSchema,
x: Math.random() * 1000, // Placeholder X
y: Math.random() * 800, // Placeholder Y
fields,
indexes: dbIndexes,
color: isMaterializedView
? materializedViewColor
: isView
? viewColor
: defaultTableColor,
isView: isView,
isMaterializedView: isMaterializedView,
createdAt: Date.now(),
comments: tableInfo.comment ? tableInfo.comment : undefined,
};
});
return result;
};

View File

@@ -1,20 +1,10 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect } from 'vitest';
import { exportBaseSQL } from '../export-sql-script'; import { exportBaseSQL } from '../export-sql-script';
import { DatabaseType } from '@/lib/domain/database-type'; import { DatabaseType } from '@/lib/domain/database-type';
import type { Diagram } from '@/lib/domain/diagram'; import type { Diagram } from '@/lib/domain/diagram';
import type { DBTable } from '@/lib/domain/db-table'; import type { DBTable } from '@/lib/domain/db-table';
import type { DBField } from '@/lib/domain/db-field'; import type { DBField } from '@/lib/domain/db-field';
// Mock the dbml/core importer
vi.mock('@dbml/core', () => ({
importer: {
import: vi.fn((sql: string) => {
// Return a simplified DBML for testing
return sql;
}),
},
}));
describe('DBML Export - SQL Generation Tests', () => { describe('DBML Export - SQL Generation Tests', () => {
// Helper to generate test IDs and timestamps // Helper to generate test IDs and timestamps
let idCounter = 0; let idCounter = 0;
@@ -116,7 +106,7 @@ describe('DBML Export - SQL Generation Tests', () => {
}); });
// Should contain composite primary key syntax // Should contain composite primary key syntax
expect(sql).toContain('PRIMARY KEY (spell_id, component_id)'); expect(sql).toContain('PRIMARY KEY ("spell_id", "component_id")');
// Should NOT contain individual PRIMARY KEY constraints // Should NOT contain individual PRIMARY KEY constraints
expect(sql).not.toMatch(/spell_id\s+uuid\s+NOT NULL\s+PRIMARY KEY/); expect(sql).not.toMatch(/spell_id\s+uuid\s+NOT NULL\s+PRIMARY KEY/);
expect(sql).not.toMatch( expect(sql).not.toMatch(
@@ -202,7 +192,7 @@ describe('DBML Export - SQL Generation Tests', () => {
// Should contain composite primary key constraint // Should contain composite primary key constraint
expect(sql).toContain( expect(sql).toContain(
'PRIMARY KEY (master_user_id, tenant_id, tenant_user_id)' 'PRIMARY KEY ("master_user_id", "tenant_id", "tenant_user_id")'
); );
// Should NOT contain the duplicate index for the primary key fields // Should NOT contain the duplicate index for the primary key fields
@@ -255,7 +245,7 @@ describe('DBML Export - SQL Generation Tests', () => {
}); });
// Should contain inline PRIMARY KEY // Should contain inline PRIMARY KEY
expect(sql).toMatch(/id\s+uuid\s+NOT NULL\s+PRIMARY KEY/); expect(sql).toMatch(/"id"\s+uuid\s+NOT NULL\s+PRIMARY KEY/);
// Should NOT contain separate PRIMARY KEY constraint // Should NOT contain separate PRIMARY KEY constraint
expect(sql).not.toContain('PRIMARY KEY (id)'); expect(sql).not.toContain('PRIMARY KEY (id)');
}); });
@@ -316,8 +306,8 @@ describe('DBML Export - SQL Generation Tests', () => {
expect(sql).not.toContain('DEFAULT has default'); expect(sql).not.toContain('DEFAULT has default');
expect(sql).not.toContain('DEFAULT DEFAULT has default'); expect(sql).not.toContain('DEFAULT DEFAULT has default');
// The fields should still be in the table // The fields should still be in the table
expect(sql).toContain('is_active boolean'); expect(sql).toContain('"is_active" boolean');
expect(sql).toContain('stock_count integer NOT NULL'); // integer gets simplified to int expect(sql).toContain('"stock_count" integer NOT NULL'); // integer gets simplified to int
}); });
it('should handle valid default values correctly', () => { it('should handle valid default values correctly', () => {
@@ -439,8 +429,8 @@ describe('DBML Export - SQL Generation Tests', () => {
}); });
// Should convert NOW to NOW() and ('now') to now() // Should convert NOW to NOW() and ('now') to now()
expect(sql).toContain('created_at timestamp DEFAULT NOW'); expect(sql).toContain('"created_at" timestamp DEFAULT NOW');
expect(sql).toContain('updated_at timestamp DEFAULT now()'); expect(sql).toContain('"updated_at" timestamp DEFAULT now()');
}); });
}); });
@@ -495,9 +485,9 @@ describe('DBML Export - SQL Generation Tests', () => {
}); });
// Should handle char with explicit length // Should handle char with explicit length
expect(sql).toContain('element_code char(2)'); expect(sql).toContain('"element_code" char(2)');
// Should add default length for char without length // Should add default length for char without length
expect(sql).toContain('status char(1)'); expect(sql).toContain('"status" char(1)');
}); });
it('should not have spaces between char and parentheses', () => { it('should not have spaces between char and parentheses', () => {
@@ -606,7 +596,7 @@ describe('DBML Export - SQL Generation Tests', () => {
}); });
// Should create a valid table without primary key // Should create a valid table without primary key
expect(sql).toContain('CREATE TABLE experiment_logs'); expect(sql).toContain('CREATE TABLE "experiment_logs"');
expect(sql).not.toContain('PRIMARY KEY'); expect(sql).not.toContain('PRIMARY KEY');
}); });
@@ -721,11 +711,11 @@ describe('DBML Export - SQL Generation Tests', () => {
}); });
// Should create both tables // Should create both tables
expect(sql).toContain('CREATE TABLE guilds'); expect(sql).toContain('CREATE TABLE "guilds"');
expect(sql).toContain('CREATE TABLE guild_members'); expect(sql).toContain('CREATE TABLE "guild_members"');
// Should create foreign key // Should create foreign key
expect(sql).toContain( expect(sql).toContain(
'ALTER TABLE guild_members ADD CONSTRAINT fk_guild_members_guild FOREIGN KEY (guild_id) REFERENCES guilds (id)' 'ALTER TABLE "guild_members" ADD CONSTRAINT fk_guild_members_guild FOREIGN KEY ("guild_id") REFERENCES "guilds" ("id");'
); );
}); });
}); });
@@ -799,12 +789,9 @@ describe('DBML Export - SQL Generation Tests', () => {
isDBMLFlow: true, isDBMLFlow: true,
}); });
// Should create schemas
expect(sql).toContain('CREATE SCHEMA IF NOT EXISTS transportation');
expect(sql).toContain('CREATE SCHEMA IF NOT EXISTS magic');
// Should use schema-qualified table names // Should use schema-qualified table names
expect(sql).toContain('CREATE TABLE transportation.portals'); expect(sql).toContain('CREATE TABLE "transportation"."portals"');
expect(sql).toContain('CREATE TABLE magic.spells'); expect(sql).toContain('CREATE TABLE "magic"."spells"');
}); });
}); });
@@ -851,7 +838,7 @@ describe('DBML Export - SQL Generation Tests', () => {
}); });
// Should still create table structure // Should still create table structure
expect(sql).toContain('CREATE TABLE empty_table'); expect(sql).toContain('CREATE TABLE "empty_table"');
expect(sql).toContain('(\n\n)'); expect(sql).toContain('(\n\n)');
}); });
@@ -952,9 +939,9 @@ describe('DBML Export - SQL Generation Tests', () => {
}); });
// Should include precision and scale // Should include precision and scale
expect(sql).toContain('amount numeric(15, 2)'); expect(sql).toContain('"amount" numeric(15, 2)');
// Should include precision only when scale is not provided // Should include precision only when scale is not provided
expect(sql).toContain('interest_rate numeric(5)'); expect(sql).toContain('"interest_rate" numeric(5)');
}); });
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,61 @@ const simplifyDataType = (typeName: string): string => {
return typeMap[typeName.toLowerCase()] || typeName; return typeMap[typeName.toLowerCase()] || typeName;
}; };
// Helper function to properly quote table/schema names with special characters
const getQuotedTableName = (
table: DBTable,
isDBMLFlow: boolean = false
): string => {
// Check if a name is already quoted
const isAlreadyQuoted = (name: string) => {
return (
(name.startsWith('"') && name.endsWith('"')) ||
(name.startsWith('`') && name.endsWith('`')) ||
(name.startsWith('[') && name.endsWith(']'))
);
};
// Only add quotes if needed and not already quoted
const quoteIfNeeded = (name: string) => {
if (isAlreadyQuoted(name)) {
return name;
}
const needsQuoting = /[^a-zA-Z0-9_]/.test(name) || isDBMLFlow;
return needsQuoting ? `"${name}"` : name;
};
if (table.schema) {
const quotedSchema = quoteIfNeeded(table.schema);
const quotedTable = quoteIfNeeded(table.name);
return `${quotedSchema}.${quotedTable}`;
} else {
return quoteIfNeeded(table.name);
}
};
const getQuotedFieldName = (
fieldName: string,
isDBMLFlow: boolean = false
): string => {
// Check if a name is already quoted
const isAlreadyQuoted = (name: string) => {
return (
(name.startsWith('"') && name.endsWith('"')) ||
(name.startsWith('`') && name.endsWith('`')) ||
(name.startsWith('[') && name.endsWith(']'))
);
};
if (isAlreadyQuoted(fieldName)) {
return fieldName;
}
// For DBML flow, always quote field names
// Otherwise, only quote if it contains special characters
const needsQuoting = /[^a-zA-Z0-9_]/.test(fieldName) || isDBMLFlow;
return needsQuoting ? `"${fieldName}"` : fieldName;
};
export const exportBaseSQL = ({ export const exportBaseSQL = ({
diagram, diagram,
targetDatabaseType, targetDatabaseType,
@@ -63,18 +118,21 @@ export const exportBaseSQL = ({
let sqlScript = ''; let sqlScript = '';
// First create the CREATE SCHEMA statements for all the found schemas based on tables // First create the CREATE SCHEMA statements for all the found schemas based on tables
const schemas = new Set<string>(); // Skip schema creation for DBML flow as DBML doesn't support CREATE SCHEMA syntax
tables.forEach((table) => { if (!isDBMLFlow) {
if (table.schema) { const schemas = new Set<string>();
schemas.add(table.schema); tables.forEach((table) => {
} if (table.schema) {
}); schemas.add(table.schema);
}
});
// Add CREATE SCHEMA statements if any schemas exist // Add CREATE SCHEMA statements if any schemas exist
schemas.forEach((schema) => { schemas.forEach((schema) => {
sqlScript += `CREATE SCHEMA IF NOT EXISTS ${schema};\n`; sqlScript += `CREATE SCHEMA IF NOT EXISTS "${schema}";\n`;
}); });
if (schemas.size > 0) sqlScript += '\n'; // Add newline only if schemas were added if (schemas.size > 0) sqlScript += '\n'; // Add newline only if schemas were added
}
// Add CREATE TYPE statements for ENUMs and COMPOSITE types from diagram.customTypes // Add CREATE TYPE statements for ENUMs and COMPOSITE types from diagram.customTypes
if (diagram.customTypes && diagram.customTypes.length > 0) { if (diagram.customTypes && diagram.customTypes.length > 0) {
@@ -166,9 +224,7 @@ export const exportBaseSQL = ({
// Loop through each non-view table to generate the SQL statements // Loop through each non-view table to generate the SQL statements
nonViewTables.forEach((table) => { nonViewTables.forEach((table) => {
const tableName = table.schema const tableName = getQuotedTableName(table, isDBMLFlow);
? `${table.schema}.${table.name}`
: table.name;
sqlScript += `CREATE TABLE ${tableName} (\n`; sqlScript += `CREATE TABLE ${tableName} (\n`;
// Check for composite primary keys // Check for composite primary keys
@@ -237,7 +293,8 @@ export const exportBaseSQL = ({
typeName = 'char'; typeName = 'char';
} }
sqlScript += ` ${field.name} ${typeName}`; const quotedFieldName = getQuotedFieldName(field.name, isDBMLFlow);
sqlScript += ` ${quotedFieldName} ${typeName}`;
// Add size for character types // Add size for character types
if ( if (
@@ -334,7 +391,9 @@ export const exportBaseSQL = ({
hasCompositePrimaryKey || hasCompositePrimaryKey ||
(primaryKeyFields.length === 1 && pkIndex?.name) (primaryKeyFields.length === 1 && pkIndex?.name)
) { ) {
const pkFieldNames = primaryKeyFields.map((f) => f.name).join(', '); const pkFieldNames = primaryKeyFields
.map((f) => getQuotedFieldName(f.name, isDBMLFlow))
.join(', ');
if (pkIndex?.name) { if (pkIndex?.name) {
sqlScript += `\n CONSTRAINT ${pkIndex.name} PRIMARY KEY (${pkFieldNames})`; sqlScript += `\n CONSTRAINT ${pkIndex.name} PRIMARY KEY (${pkFieldNames})`;
} else { } else {
@@ -355,7 +414,11 @@ export const exportBaseSQL = ({
table.fields.forEach((field) => { table.fields.forEach((field) => {
// Add column comment (only for databases that support COMMENT ON syntax) // Add column comment (only for databases that support COMMENT ON syntax)
if (field.comments && supportsCommentOn) { if (field.comments && supportsCommentOn) {
sqlScript += `COMMENT ON COLUMN ${tableName}.${field.name} IS '${escapeSQLComment(field.comments)}';\n`; const quotedFieldName = getQuotedFieldName(
field.name,
isDBMLFlow
);
sqlScript += `COMMENT ON COLUMN ${tableName}.${quotedFieldName} IS '${escapeSQLComment(field.comments)}';\n`;
} }
}); });
@@ -387,7 +450,7 @@ export const exportBaseSQL = ({
} }
const fieldNames = indexFields const fieldNames = indexFields
.map((field) => field.name) .map((field) => getQuotedFieldName(field.name, isDBMLFlow))
.join(', '); .join(', ');
if (fieldNames) { if (fieldNames) {
@@ -465,13 +528,18 @@ export const exportBaseSQL = ({
return; return;
} }
const fkTableName = fkTable.schema const fkTableName = getQuotedTableName(fkTable, isDBMLFlow);
? `${fkTable.schema}.${fkTable.name}` const refTableName = getQuotedTableName(refTable, isDBMLFlow);
: fkTable.name; const quotedFkFieldName = getQuotedFieldName(
const refTableName = refTable.schema fkField.name,
? `${refTable.schema}.${refTable.name}` isDBMLFlow
: refTable.name; );
sqlScript += `ALTER TABLE ${fkTableName} ADD CONSTRAINT ${relationship.name} FOREIGN KEY (${fkField.name}) REFERENCES ${refTableName} (${refField.name});\n`; const quotedRefFieldName = getQuotedFieldName(
refField.name,
isDBMLFlow
);
sqlScript += `ALTER TABLE ${fkTableName} ADD CONSTRAINT ${relationship.name} FOREIGN KEY (${quotedFkFieldName}) REFERENCES ${refTableName} (${quotedRefFieldName});\n`;
} }
}); });

View File

@@ -86,7 +86,7 @@ export interface SQLBinaryExpr extends SQLASTNode {
export interface SQLFunctionNode extends SQLASTNode { export interface SQLFunctionNode extends SQLASTNode {
type: 'function'; type: 'function';
name: string; name: string | { name: Array<{ value: string }> };
args?: { args?: {
value: SQLASTArg[]; value: SQLASTArg[];
}; };
@@ -108,6 +108,31 @@ export interface SQLStringLiteral extends SQLASTNode {
value: string; value: string;
} }
export interface SQLDefaultNode extends SQLASTNode {
type: 'default';
value: SQLASTNode;
}
export interface SQLCastNode extends SQLASTNode {
type: 'cast';
expr: SQLASTNode;
target: Array<{ dataType: string }>;
}
export interface SQLBooleanNode extends SQLASTNode {
type: 'bool';
value: boolean;
}
export interface SQLNullNode extends SQLASTNode {
type: 'null';
}
export interface SQLNumberNode extends SQLASTNode {
type: 'number';
value: number;
}
export type SQLASTArg = export type SQLASTArg =
| SQLColumnRef | SQLColumnRef
| SQLStringLiteral | SQLStringLiteral
@@ -146,6 +171,22 @@ export function buildSQLFromAST(
): string { ): string {
if (!ast) return ''; if (!ast) return '';
// Handle default value wrapper
if (ast.type === 'default' && 'value' in ast) {
const defaultNode = ast as SQLDefaultNode;
return buildSQLFromAST(defaultNode.value, dbType);
}
// Handle PostgreSQL cast expressions (e.g., 'value'::type)
if (ast.type === 'cast' && 'expr' in ast && 'target' in ast) {
const castNode = ast as SQLCastNode;
const expr = buildSQLFromAST(castNode.expr, dbType);
if (castNode.target.length > 0 && castNode.target[0].dataType) {
return `${expr}::${castNode.target[0].dataType.toLowerCase()}`;
}
return expr;
}
if (ast.type === 'binary_expr') { if (ast.type === 'binary_expr') {
const expr = ast as SQLBinaryExpr; const expr = ast as SQLBinaryExpr;
const leftSQL = buildSQLFromAST(expr.left, dbType); const leftSQL = buildSQLFromAST(expr.left, dbType);
@@ -155,7 +196,59 @@ export function buildSQLFromAST(
if (ast.type === 'function') { if (ast.type === 'function') {
const func = ast as SQLFunctionNode; const func = ast as SQLFunctionNode;
let expr = func.name; let funcName = '';
// Handle nested function name structure
if (typeof func.name === 'object' && func.name && 'name' in func.name) {
const nameObj = func.name as { name: Array<{ value: string }> };
if (nameObj.name.length > 0) {
funcName = nameObj.name[0].value || '';
}
} else if (typeof func.name === 'string') {
funcName = func.name;
}
if (!funcName) return '';
// Normalize PostgreSQL function names to uppercase for consistency
if (dbType === DatabaseType.POSTGRESQL) {
const pgFunctions = [
'now',
'current_timestamp',
'current_date',
'current_time',
'gen_random_uuid',
'random',
'nextval',
'currval',
];
if (pgFunctions.includes(funcName.toLowerCase())) {
funcName = funcName.toUpperCase();
}
}
// Some PostgreSQL functions don't have parentheses (like CURRENT_TIMESTAMP)
if (funcName === 'CURRENT_TIMESTAMP' && !func.args) {
return funcName;
}
// Handle SQL Server function defaults that were preprocessed as strings
// The preprocessor converts NEWID() to 'newid', GETDATE() to 'getdate', etc.
if (dbType === DatabaseType.SQL_SERVER) {
const sqlServerFunctions: Record<string, string> = {
newid: 'NEWID()',
newsequentialid: 'NEWSEQUENTIALID()',
getdate: 'GETDATE()',
sysdatetime: 'SYSDATETIME()',
};
const lowerFuncName = funcName.toLowerCase();
if (sqlServerFunctions[lowerFuncName]) {
return sqlServerFunctions[lowerFuncName];
}
}
let expr = funcName;
if (func.args) { if (func.args) {
expr += expr +=
'(' + '(' +
@@ -175,12 +268,31 @@ export function buildSQLFromAST(
}) })
.join(', ') + .join(', ') +
')'; ')';
} else {
expr += '()';
} }
return expr; return expr;
} else if (ast.type === 'column_ref') { } else if (ast.type === 'column_ref') {
return quoteIdentifier((ast as SQLColumnRef).column, dbType); return quoteIdentifier((ast as SQLColumnRef).column, dbType);
} else if (ast.type === 'expr_list') { } else if (ast.type === 'expr_list') {
return (ast as SQLExprList).value.map((v) => v.value).join(' AND '); return (ast as SQLExprList).value.map((v) => v.value).join(' AND ');
} else if (ast.type === 'single_quote_string') {
// String literal with single quotes
const strNode = ast as SQLStringLiteral;
return `'${strNode.value}'`;
} else if (ast.type === 'double_quote_string') {
// String literal with double quotes
const strNode = ast as SQLStringLiteral;
return `"${strNode.value}"`;
} else if (ast.type === 'bool') {
// Boolean value
const boolNode = ast as SQLBooleanNode;
return boolNode.value ? 'TRUE' : 'FALSE';
} else if (ast.type === 'null') {
return 'NULL';
} else if (ast.type === 'number') {
const numNode = ast as SQLNumberNode;
return String(numNode.value);
} else { } else {
const valueNode = ast as { type: string; value: string | number }; const valueNode = ast as { type: string; value: string | number };
return typeof valueNode.value === 'string' return typeof valueNode.value === 'string'
@@ -779,10 +891,10 @@ export function convertToChartDBDiagram(
} }
const sourceField = sourceTable.fields.find( const sourceField = sourceTable.fields.find(
(f) => f.name === rel.sourceColumn (f) => f.name.toLowerCase() === rel.sourceColumn.toLowerCase()
); );
const targetField = targetTable.fields.find( const targetField = targetTable.fields.find(
(f) => f.name === rel.targetColumn (f) => f.name.toLowerCase() === rel.targetColumn.toLowerCase()
); );
if (!sourceField || !targetField) { if (!sourceField || !targetField) {

View File

@@ -0,0 +1,228 @@
import { describe, it, expect } from 'vitest';
import { fromMySQL } from '../mysql';
describe('MySQL Default Value Import', () => {
describe('String Default Values', () => {
it('should parse simple string defaults with single quotes', async () => {
const sql = `
CREATE TABLE tavern_patrons (
patron_id INT NOT NULL,
membership_status VARCHAR(50) DEFAULT 'regular',
PRIMARY KEY (patron_id)
);
`;
const result = await fromMySQL(sql);
expect(result.tables).toHaveLength(1);
const statusColumn = result.tables[0].columns.find(
(c) => c.name === 'membership_status'
);
expect(statusColumn?.default).toBe("'regular'");
});
it('should parse string defaults with escaped quotes', async () => {
const sql = `
CREATE TABLE wizard_spellbooks (
spellbook_id INT NOT NULL,
incantation VARCHAR(255) DEFAULT 'Dragon\\'s flame',
spell_metadata TEXT DEFAULT '{"type": "fire"}',
PRIMARY KEY (spellbook_id)
);
`;
const result = await fromMySQL(sql);
expect(result.tables).toHaveLength(1);
const incantationColumn = result.tables[0].columns.find(
(c) => c.name === 'incantation'
);
expect(incantationColumn?.default).toBeTruthy();
const metadataColumn = result.tables[0].columns.find(
(c) => c.name === 'spell_metadata'
);
expect(metadataColumn?.default).toBeTruthy();
});
});
describe('Numeric Default Values', () => {
it('should parse integer defaults', async () => {
const sql = `
CREATE TABLE dungeon_levels (
level_id INT NOT NULL,
monster_count INT DEFAULT 0,
max_treasure INT DEFAULT 1000,
PRIMARY KEY (level_id)
);
`;
const result = await fromMySQL(sql);
expect(result.tables).toHaveLength(1);
const monsterColumn = result.tables[0].columns.find(
(c) => c.name === 'monster_count'
);
expect(monsterColumn?.default).toBe('0');
const treasureColumn = result.tables[0].columns.find(
(c) => c.name === 'max_treasure'
);
expect(treasureColumn?.default).toBe('1000');
});
it('should parse decimal defaults', async () => {
const sql = `
CREATE TABLE merchant_inventory (
item_id INT NOT NULL,
base_price DECIMAL(10, 2) DEFAULT 99.99,
loyalty_discount FLOAT DEFAULT 0.15,
PRIMARY KEY (item_id)
);
`;
const result = await fromMySQL(sql);
expect(result.tables).toHaveLength(1);
const priceColumn = result.tables[0].columns.find(
(c) => c.name === 'base_price'
);
expect(priceColumn?.default).toBe('99.99');
const discountColumn = result.tables[0].columns.find(
(c) => c.name === 'loyalty_discount'
);
expect(discountColumn?.default).toBe('0.15');
});
});
describe('Boolean Default Values', () => {
it('should parse boolean defaults in MySQL (using TINYINT)', async () => {
const sql = `
CREATE TABLE character_status (
character_id INT NOT NULL,
is_alive TINYINT(1) DEFAULT 1,
is_cursed TINYINT(1) DEFAULT 0,
has_magic BOOLEAN DEFAULT TRUE,
PRIMARY KEY (character_id)
);
`;
const result = await fromMySQL(sql);
expect(result.tables).toHaveLength(1);
const aliveColumn = result.tables[0].columns.find(
(c) => c.name === 'is_alive'
);
expect(aliveColumn?.default).toBe('1');
const cursedColumn = result.tables[0].columns.find(
(c) => c.name === 'is_cursed'
);
expect(cursedColumn?.default).toBe('0');
});
});
describe('NULL Default Values', () => {
it('should parse NULL defaults', async () => {
const sql = `
CREATE TABLE companion_animals (
companion_id INT NOT NULL,
special_trait VARCHAR(255) DEFAULT NULL,
PRIMARY KEY (companion_id)
);
`;
const result = await fromMySQL(sql);
expect(result.tables).toHaveLength(1);
const traitColumn = result.tables[0].columns.find(
(c) => c.name === 'special_trait'
);
expect(traitColumn?.default).toBe('NULL');
});
});
describe('Function Default Values', () => {
it('should parse function defaults', async () => {
const sql = `
CREATE TABLE quest_entries (
entry_id INT NOT NULL AUTO_INCREMENT,
quest_accepted TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
quest_uuid VARCHAR(36) DEFAULT (UUID()),
PRIMARY KEY (entry_id)
);
`;
const result = await fromMySQL(sql);
expect(result.tables).toHaveLength(1);
const acceptedColumn = result.tables[0].columns.find(
(c) => c.name === 'quest_accepted'
);
expect(acceptedColumn?.default).toBe('CURRENT_TIMESTAMP');
const updatedColumn = result.tables[0].columns.find(
(c) => c.name === 'last_updated'
);
expect(updatedColumn?.default).toBe('CURRENT_TIMESTAMP');
});
});
describe('AUTO_INCREMENT', () => {
it('should handle AUTO_INCREMENT columns correctly', async () => {
const sql = `
CREATE TABLE hero_registry (
hero_id INT NOT NULL AUTO_INCREMENT,
hero_name VARCHAR(100),
PRIMARY KEY (hero_id)
);
`;
const result = await fromMySQL(sql);
expect(result.tables).toHaveLength(1);
const idColumn = result.tables[0].columns.find(
(c) => c.name === 'hero_id'
);
expect(idColumn?.increment).toBe(true);
// AUTO_INCREMENT columns typically don't have a default value
expect(idColumn?.default).toBeUndefined();
});
});
describe('Complex Real-World Example', () => {
it('should handle complex table with multiple default types', async () => {
const sql = `
CREATE TABLE adventurer_profiles (
adventurer_id BIGINT NOT NULL AUTO_INCREMENT,
character_name VARCHAR(50) NOT NULL,
guild_email VARCHAR(255) NOT NULL,
rank VARCHAR(20) DEFAULT 'novice',
is_guild_verified TINYINT(1) DEFAULT 0,
gold_coins INT DEFAULT 100,
account_balance DECIMAL(10, 2) DEFAULT 0.00,
joined_realm TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_quest TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
inventory_data JSON DEFAULT NULL,
PRIMARY KEY (adventurer_id),
UNIQUE KEY uk_guild_email (guild_email),
INDEX idx_rank (rank)
);
`;
const result = await fromMySQL(sql);
const table = result.tables[0];
expect(table).toBeDefined();
// Check various default values
const rankColumn = table.columns.find((c) => c.name === 'rank');
expect(rankColumn?.default).toBe("'novice'");
const verifiedColumn = table.columns.find(
(c) => c.name === 'is_guild_verified'
);
expect(verifiedColumn?.default).toBe('0');
const goldColumn = table.columns.find(
(c) => c.name === 'gold_coins'
);
expect(goldColumn?.default).toBe('100');
const balanceColumn = table.columns.find(
(c) => c.name === 'account_balance'
);
expect(balanceColumn?.default).toBe('0.00');
const joinedColumn = table.columns.find(
(c) => c.name === 'joined_realm'
);
expect(joinedColumn?.default).toBe('CURRENT_TIMESTAMP');
const inventoryColumn = table.columns.find(
(c) => c.name === 'inventory_data'
);
expect(inventoryColumn?.default).toBe('NULL');
});
});
});

View File

@@ -101,12 +101,28 @@ function extractColumnsFromCreateTable(statement: string): SQLColumn[] {
const typeMatch = definition.match(/^([^\s(]+)(?:\(([^)]+)\))?/); const typeMatch = definition.match(/^([^\s(]+)(?:\(([^)]+)\))?/);
const dataType = typeMatch ? typeMatch[1] : ''; const dataType = typeMatch ? typeMatch[1] : '';
// Extract default value
let defaultValue: string | undefined;
const defaultMatch = definition.match(
/DEFAULT\s+('[^']*'|"[^"]*"|NULL|CURRENT_TIMESTAMP|\S+)/i
);
if (defaultMatch) {
defaultValue = defaultMatch[1];
}
// Check for AUTO_INCREMENT
const increment = definition
.toUpperCase()
.includes('AUTO_INCREMENT');
columns.push({ columns.push({
name: columnName, name: columnName,
type: dataType, type: dataType,
nullable, nullable,
primaryKey, primaryKey,
unique: definition.toUpperCase().includes('UNIQUE'), unique: definition.toUpperCase().includes('UNIQUE'),
default: defaultValue,
increment,
}); });
} }
} }
@@ -721,7 +737,28 @@ export async function fromMySQL(sqlContent: string): Promise<SQLParserResult> {
parseError parseError
); );
// Error handling without logging // Try fallback parser when main parser fails
const tableMatch = trimmedStmt.match(
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([^`\s(]+)`?\s*\(/i
);
if (tableMatch) {
const tableName = tableMatch[1];
const tableId = generateId();
tableMap[tableName] = tableId;
const extractedColumns =
extractColumnsFromCreateTable(trimmedStmt);
if (extractedColumns.length > 0) {
tables.push({
id: tableId,
name: tableName,
schema: undefined,
columns: extractedColumns,
indexes: [],
order: tables.length,
});
}
}
} }
} }
} }

View File

@@ -0,0 +1,215 @@
import { describe, it, expect } from 'vitest';
import { fromPostgres } from '../postgresql';
describe('PostgreSQL ALTER TABLE ADD COLUMN Tests', () => {
it('should handle ALTER TABLE ADD COLUMN statements', async () => {
const sql = `
CREATE SCHEMA IF NOT EXISTS "public";
CREATE TABLE "public"."location" (
"id" bigint NOT NULL,
CONSTRAINT "pk_table_7_id" PRIMARY KEY ("id")
);
-- Add new fields to existing location table
ALTER TABLE location ADD COLUMN country_id INT;
ALTER TABLE location ADD COLUMN state_id INT;
ALTER TABLE location ADD COLUMN location_type_id INT;
ALTER TABLE location ADD COLUMN city_id INT;
ALTER TABLE location ADD COLUMN street TEXT;
ALTER TABLE location ADD COLUMN block TEXT;
ALTER TABLE location ADD COLUMN building TEXT;
ALTER TABLE location ADD COLUMN floor TEXT;
ALTER TABLE location ADD COLUMN apartment TEXT;
ALTER TABLE location ADD COLUMN lat INT;
ALTER TABLE location ADD COLUMN long INT;
ALTER TABLE location ADD COLUMN elevation INT;
ALTER TABLE location ADD COLUMN erp_site_id INT;
ALTER TABLE location ADD COLUMN is_active TEXT;
ALTER TABLE location ADD COLUMN remarks TEXT;
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const locationTable = result.tables[0];
expect(locationTable.name).toBe('location');
expect(locationTable.schema).toBe('public');
// Should have the original id column plus all the added columns
expect(locationTable.columns).toHaveLength(16);
// Check that the id column is present
const idColumn = locationTable.columns.find((col) => col.name === 'id');
expect(idColumn).toBeDefined();
expect(idColumn?.type).toBe('BIGINT');
expect(idColumn?.primaryKey).toBe(true);
// Check some of the added columns
const countryIdColumn = locationTable.columns.find(
(col) => col.name === 'country_id'
);
expect(countryIdColumn).toBeDefined();
expect(countryIdColumn?.type).toBe('INTEGER');
const streetColumn = locationTable.columns.find(
(col) => col.name === 'street'
);
expect(streetColumn).toBeDefined();
expect(streetColumn?.type).toBe('TEXT');
const remarksColumn = locationTable.columns.find(
(col) => col.name === 'remarks'
);
expect(remarksColumn).toBeDefined();
expect(remarksColumn?.type).toBe('TEXT');
});
it('should handle ALTER TABLE ADD COLUMN with schema qualification', async () => {
const sql = `
CREATE TABLE public.users (
id INTEGER PRIMARY KEY
);
ALTER TABLE public.users ADD COLUMN email VARCHAR(255);
ALTER TABLE public.users ADD COLUMN created_at TIMESTAMP;
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const usersTable = result.tables[0];
expect(usersTable.columns).toHaveLength(3);
const emailColumn = usersTable.columns.find(
(col) => col.name === 'email'
);
expect(emailColumn).toBeDefined();
expect(emailColumn?.type).toBe('VARCHAR(255)');
const createdAtColumn = usersTable.columns.find(
(col) => col.name === 'created_at'
);
expect(createdAtColumn).toBeDefined();
expect(createdAtColumn?.type).toBe('TIMESTAMP');
});
it('should handle ALTER TABLE ADD COLUMN with constraints', async () => {
const sql = `
CREATE TABLE products (
id SERIAL PRIMARY KEY
);
ALTER TABLE products ADD COLUMN name VARCHAR(100) NOT NULL;
ALTER TABLE products ADD COLUMN sku VARCHAR(50) UNIQUE;
ALTER TABLE products ADD COLUMN price DECIMAL(10,2) DEFAULT 0.00;
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const productsTable = result.tables[0];
expect(productsTable.columns).toHaveLength(4);
const nameColumn = productsTable.columns.find(
(col) => col.name === 'name'
);
expect(nameColumn).toBeDefined();
expect(nameColumn?.nullable).toBe(false);
const skuColumn = productsTable.columns.find(
(col) => col.name === 'sku'
);
expect(skuColumn).toBeDefined();
expect(skuColumn?.unique).toBe(true);
const priceColumn = productsTable.columns.find(
(col) => col.name === 'price'
);
expect(priceColumn).toBeDefined();
expect(priceColumn?.default).toBe('0');
});
it('should not add duplicate columns', async () => {
const sql = `
CREATE TABLE items (
id INTEGER PRIMARY KEY,
name VARCHAR(100)
);
ALTER TABLE items ADD COLUMN description TEXT;
ALTER TABLE items ADD COLUMN name VARCHAR(200); -- Should not be added as duplicate
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const itemsTable = result.tables[0];
// Should only have 3 columns: id, name (original), and description
expect(itemsTable.columns).toHaveLength(3);
const nameColumns = itemsTable.columns.filter(
(col) => col.name === 'name'
);
expect(nameColumns).toHaveLength(1);
expect(nameColumns[0].type).toBe('VARCHAR(100)'); // Should keep original type
});
it('should use default schema when not specified', async () => {
const sql = `
CREATE TABLE test_table (
id INTEGER PRIMARY KEY
);
ALTER TABLE test_table ADD COLUMN value TEXT;
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const testTable = result.tables[0];
expect(testTable.schema).toBe('public');
expect(testTable.columns).toHaveLength(2);
const valueColumn = testTable.columns.find(
(col) => col.name === 'value'
);
expect(valueColumn).toBeDefined();
});
it('should handle quoted identifiers in ALTER TABLE ADD COLUMN', async () => {
const sql = `
CREATE TABLE "my-table" (
"id" INTEGER PRIMARY KEY
);
ALTER TABLE "my-table" ADD COLUMN "my-column" VARCHAR(50);
ALTER TABLE "my-table" ADD COLUMN "another-column" INTEGER;
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const myTable = result.tables[0];
expect(myTable.name).toBe('my-table');
expect(myTable.columns).toHaveLength(3);
const myColumn = myTable.columns.find(
(col) => col.name === 'my-column'
);
expect(myColumn).toBeDefined();
expect(myColumn?.type).toBe('VARCHAR(50)');
const anotherColumn = myTable.columns.find(
(col) => col.name === 'another-column'
);
expect(anotherColumn).toBeDefined();
expect(anotherColumn?.type).toBe('INTEGER');
});
});

View File

@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest';
import { fromPostgres } from '../postgresql';
describe('PostgreSQL ALTER TABLE ALTER COLUMN TYPE', () => {
it('should handle ALTER TABLE ALTER COLUMN TYPE statements', async () => {
const sql = `
CREATE SCHEMA IF NOT EXISTS "public";
CREATE TABLE "public"."table_12" (
"id" SERIAL,
"field1" varchar(200),
"field2" varchar(200),
"field3" varchar(200),
PRIMARY KEY ("id")
);
ALTER TABLE table_12 ALTER COLUMN field1 TYPE VARCHAR(254);
ALTER TABLE table_12 ALTER COLUMN field2 TYPE VARCHAR(254);
ALTER TABLE table_12 ALTER COLUMN field3 TYPE VARCHAR(254);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const table = result.tables[0];
expect(table.name).toBe('table_12');
expect(table.columns).toHaveLength(4); // id, field1, field2, field3
// Check that the columns have the updated type
const field1 = table.columns.find((col) => col.name === 'field1');
expect(field1).toBeDefined();
expect(field1?.type).toBe('VARCHAR(254)'); // Should be updated from 200 to 254
const field2 = table.columns.find((col) => col.name === 'field2');
expect(field2).toBeDefined();
expect(field2?.type).toBe('VARCHAR(254)');
const field3 = table.columns.find((col) => col.name === 'field3');
expect(field3).toBeDefined();
expect(field3?.type).toBe('VARCHAR(254)');
});
it('should handle various ALTER COLUMN TYPE scenarios', async () => {
const sql = `
CREATE TABLE test_table (
id INTEGER PRIMARY KEY,
name VARCHAR(50),
age SMALLINT,
score NUMERIC(5,2)
);
-- Change varchar length
ALTER TABLE test_table ALTER COLUMN name TYPE VARCHAR(100);
-- Change numeric type
ALTER TABLE test_table ALTER COLUMN age TYPE INTEGER;
-- Change precision
ALTER TABLE test_table ALTER COLUMN score TYPE NUMERIC(10,4);
`;
const result = await fromPostgres(sql);
const table = result.tables[0];
const nameCol = table.columns.find((col) => col.name === 'name');
expect(nameCol?.type).toBe('VARCHAR(100)');
const ageCol = table.columns.find((col) => col.name === 'age');
expect(ageCol?.type).toBe('INTEGER');
const scoreCol = table.columns.find((col) => col.name === 'score');
expect(scoreCol?.type).toBe('NUMERIC(10,4)');
});
it('should handle multiple type changes on the same column', async () => {
const sql = `
CREATE SCHEMA IF NOT EXISTS "public";
CREATE TABLE "public"."table_12" (
"id" SERIAL,
"field1" varchar(200),
"field2" varchar(200),
"field3" varchar(200),
PRIMARY KEY ("id")
);
ALTER TABLE table_12 ALTER COLUMN field1 TYPE VARCHAR(254);
ALTER TABLE table_12 ALTER COLUMN field2 TYPE VARCHAR(254);
ALTER TABLE table_12 ALTER COLUMN field3 TYPE VARCHAR(254);
ALTER TABLE table_12 ALTER COLUMN field1 TYPE BIGINT;
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const table = result.tables[0];
expect(table.name).toBe('table_12');
expect(table.schema).toBe('public');
expect(table.columns).toHaveLength(4);
// Check that field1 has the final type (BIGINT), not the intermediate VARCHAR(254)
const field1 = table.columns.find((col) => col.name === 'field1');
expect(field1).toBeDefined();
expect(field1?.type).toBe('BIGINT'); // Should be BIGINT, not VARCHAR(254)
// Check that field2 and field3 still have VARCHAR(254)
const field2 = table.columns.find((col) => col.name === 'field2');
expect(field2).toBeDefined();
expect(field2?.type).toBe('VARCHAR(254)');
const field3 = table.columns.find((col) => col.name === 'field3');
expect(field3).toBeDefined();
expect(field3?.type).toBe('VARCHAR(254)');
});
});

View File

@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import { fromPostgres } from '../postgresql';
describe('PostgreSQL ALTER TABLE with Foreign Keys', () => {
it('should handle ALTER TABLE ADD COLUMN followed by ALTER TABLE ADD FOREIGN KEY', async () => {
const sql = `
CREATE SCHEMA IF NOT EXISTS "public";
CREATE TABLE "public"."location" (
"id" bigint NOT NULL,
CONSTRAINT "pk_table_7_id" PRIMARY KEY ("id")
);
-- Add new fields to existing location table
ALTER TABLE location ADD COLUMN country_id INT;
ALTER TABLE location ADD COLUMN state_id INT;
ALTER TABLE location ADD COLUMN location_type_id INT;
ALTER TABLE location ADD COLUMN city_id INT;
ALTER TABLE location ADD COLUMN street TEXT;
ALTER TABLE location ADD COLUMN block TEXT;
ALTER TABLE location ADD COLUMN building TEXT;
ALTER TABLE location ADD COLUMN floor TEXT;
ALTER TABLE location ADD COLUMN apartment TEXT;
ALTER TABLE location ADD COLUMN lat INT;
ALTER TABLE location ADD COLUMN long INT;
ALTER TABLE location ADD COLUMN elevation INT;
ALTER TABLE location ADD COLUMN erp_site_id INT;
ALTER TABLE location ADD COLUMN is_active TEXT;
ALTER TABLE location ADD COLUMN remarks TEXT;
-- Create lookup tables
CREATE TABLE country (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
code VARCHAR(3) UNIQUE
);
CREATE TABLE state (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
country_id INT NOT NULL,
FOREIGN KEY (country_id) REFERENCES country(id)
);
CREATE TABLE location_type (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
CREATE TABLE city (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
state_id INT NOT NULL,
FOREIGN KEY (state_id) REFERENCES state(id)
);
-- Add foreign key constraints from location to lookup tables
ALTER TABLE location ADD CONSTRAINT fk_location_country
FOREIGN KEY (country_id) REFERENCES country(id);
ALTER TABLE location ADD CONSTRAINT fk_location_state
FOREIGN KEY (state_id) REFERENCES state(id);
ALTER TABLE location ADD CONSTRAINT fk_location_location_type
FOREIGN KEY (location_type_id) REFERENCES location_type(id);
ALTER TABLE location ADD CONSTRAINT fk_location_city
FOREIGN KEY (city_id) REFERENCES city(id);
`;
const result = await fromPostgres(sql);
const locationTable = result.tables.find((t) => t.name === 'location');
// Check tables
expect(result.tables).toHaveLength(5); // location, country, state, location_type, city
// Check location table has all columns
expect(locationTable).toBeDefined();
expect(locationTable?.columns).toHaveLength(16); // id + 15 added columns
// Check foreign key relationships
const locationRelationships = result.relationships.filter(
(r) => r.sourceTable === 'location'
);
// Should have 4 FKs from location to lookup tables + 2 from state/city
expect(result.relationships.length).toBeGreaterThanOrEqual(6);
// Check specific foreign keys from location
expect(
locationRelationships.some(
(r) =>
r.sourceColumn === 'country_id' &&
r.targetTable === 'country'
)
).toBe(true);
expect(
locationRelationships.some(
(r) =>
r.sourceColumn === 'state_id' && r.targetTable === 'state'
)
).toBe(true);
expect(
locationRelationships.some(
(r) =>
r.sourceColumn === 'location_type_id' &&
r.targetTable === 'location_type'
)
).toBe(true);
expect(
locationRelationships.some(
(r) => r.sourceColumn === 'city_id' && r.targetTable === 'city'
)
).toBe(true);
});
});

View File

@@ -0,0 +1,395 @@
import { describe, it, expect } from 'vitest';
import { fromPostgres } from '../postgresql';
describe('PostgreSQL Default Value Import', () => {
describe('String Default Values', () => {
it('should parse simple string defaults with single quotes', async () => {
const sql = `
CREATE TABLE heroes (
hero_id INTEGER NOT NULL,
hero_status CHARACTER VARYING DEFAULT 'questing',
PRIMARY KEY (hero_id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const statusColumn = result.tables[0].columns.find(
(c) => c.name === 'hero_status'
);
expect(statusColumn?.default).toBe("'questing'");
});
it('should parse string defaults with special characters that need escaping', async () => {
const sql = `
CREATE TABLE spell_scrolls (
scroll_id INTEGER NOT NULL,
incantation CHARACTER VARYING DEFAULT 'Dragon''s breath',
rune_inscription TEXT DEFAULT 'Ancient rune
Sacred symbol',
PRIMARY KEY (scroll_id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const incantationColumn = result.tables[0].columns.find(
(c) => c.name === 'incantation'
);
expect(incantationColumn?.default).toBe("'Dragon''s breath'");
});
it('should parse elvish text default values', async () => {
const sql = `
CREATE TABLE elven_greetings (
greeting_id INTEGER NOT NULL,
elvish_welcome CHARACTER VARYING DEFAULT 'Mae govannen',
PRIMARY KEY (greeting_id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const greetingColumn = result.tables[0].columns.find(
(c) => c.name === 'elvish_welcome'
);
expect(greetingColumn?.default).toBe("'Mae govannen'");
});
});
describe('Numeric Default Values', () => {
it('should parse integer defaults', async () => {
const sql = `
CREATE TABLE dragon_hoards (
hoard_id INTEGER NOT NULL,
gold_pieces INTEGER DEFAULT 0,
max_treasure_value INTEGER DEFAULT 10000,
PRIMARY KEY (hoard_id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const goldColumn = result.tables[0].columns.find(
(c) => c.name === 'gold_pieces'
);
expect(goldColumn?.default).toBe('0');
const treasureColumn = result.tables[0].columns.find(
(c) => c.name === 'max_treasure_value'
);
expect(treasureColumn?.default).toBe('10000');
});
it('should parse decimal defaults', async () => {
const sql = `
CREATE TABLE enchanted_items (
item_id INTEGER NOT NULL,
market_price DECIMAL(10, 2) DEFAULT 99.99,
magic_power_rating NUMERIC DEFAULT 0.85,
PRIMARY KEY (item_id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const priceColumn = result.tables[0].columns.find(
(c) => c.name === 'market_price'
);
expect(priceColumn?.default).toBe('99.99');
const powerColumn = result.tables[0].columns.find(
(c) => c.name === 'magic_power_rating'
);
expect(powerColumn?.default).toBe('0.85');
});
});
describe('Boolean Default Values', () => {
it('should parse boolean defaults', async () => {
const sql = `
CREATE TABLE magical_artifacts (
artifact_id INTEGER NOT NULL,
is_cursed BOOLEAN DEFAULT TRUE,
is_destroyed BOOLEAN DEFAULT FALSE,
is_legendary BOOLEAN DEFAULT '1',
is_identified BOOLEAN DEFAULT '0',
PRIMARY KEY (artifact_id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const cursedColumn = result.tables[0].columns.find(
(c) => c.name === 'is_cursed'
);
expect(cursedColumn?.default).toBe('TRUE');
const destroyedColumn = result.tables[0].columns.find(
(c) => c.name === 'is_destroyed'
);
expect(destroyedColumn?.default).toBe('FALSE');
const legendaryColumn = result.tables[0].columns.find(
(c) => c.name === 'is_legendary'
);
expect(legendaryColumn?.default).toBe("'1'");
const identifiedColumn = result.tables[0].columns.find(
(c) => c.name === 'is_identified'
);
expect(identifiedColumn?.default).toBe("'0'");
});
});
describe('NULL Default Values', () => {
it('should parse NULL defaults', async () => {
const sql = `
CREATE TABLE wizard_familiars (
familiar_id INTEGER NOT NULL,
special_ability CHARACTER VARYING DEFAULT NULL,
PRIMARY KEY (familiar_id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const abilityColumn = result.tables[0].columns.find(
(c) => c.name === 'special_ability'
);
expect(abilityColumn?.default).toBe('NULL');
});
});
describe('Function Default Values', () => {
it('should parse function defaults', async () => {
const sql = `
CREATE TABLE quest_logs (
quest_id UUID DEFAULT gen_random_uuid(),
quest_started TIMESTAMP DEFAULT NOW(),
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
difficulty_roll INTEGER DEFAULT random()
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const questIdColumn = result.tables[0].columns.find(
(c) => c.name === 'quest_id'
);
expect(questIdColumn?.default).toBe('GEN_RANDOM_UUID()');
const startedColumn = result.tables[0].columns.find(
(c) => c.name === 'quest_started'
);
expect(startedColumn?.default).toBe('NOW()');
const updatedColumn = result.tables[0].columns.find(
(c) => c.name === 'last_updated'
);
expect(updatedColumn?.default).toBe('CURRENT_TIMESTAMP');
const difficultyColumn = result.tables[0].columns.find(
(c) => c.name === 'difficulty_roll'
);
expect(difficultyColumn?.default).toBe('RANDOM()');
});
});
describe('Complex Real-World Example', () => {
it('should handle a complex guild management table correctly', async () => {
const sql = `
CREATE TABLE "realm"(
"realm_id" integer NOT NULL
);
CREATE TABLE "guild"(
"guild_id" CHARACTER VARYING NOT NULL UNIQUE,
PRIMARY KEY ("guild_id")
);
CREATE TABLE "guild_schedule"(
"schedule_id" CHARACTER VARYING NOT NULL UNIQUE,
PRIMARY KEY ("schedule_id")
);
CREATE TABLE "guild_quests"(
"is_active" CHARACTER VARYING NOT NULL DEFAULT 'active',
"quest_description" CHARACTER VARYING,
"quest_type" CHARACTER VARYING,
"quest_status" CHARACTER VARYING DEFAULT 'pending',
"quest_id" CHARACTER VARYING NOT NULL UNIQUE,
"reward_gold" CHARACTER VARYING,
"quest_giver" CHARACTER VARYING,
"party_size" CHARACTER VARYING,
"difficulty_level" CHARACTER VARYING,
"monster_type" CHARACTER VARYING,
"dungeon_location" CHARACTER VARYING,
"main_guild_ref" CHARACTER VARYING NOT NULL,
"schedule_ref" CHARACTER VARYING,
"last_attempt" CHARACTER VARYING,
"max_attempts" INTEGER,
"failed_attempts" INTEGER,
"party_members" INTEGER,
"loot_distributor" CHARACTER VARYING,
"quest_validator" CHARACTER VARYING,
"scout_report" CHARACTER VARYING,
"completion_xp" INTEGER,
"bonus_xp" INTEGER,
"map_coordinates" CHARACTER VARYING,
"quest_correlation" CHARACTER VARYING,
"is_completed" BOOLEAN NOT NULL DEFAULT '0',
"reward_items" CHARACTER VARYING,
"quest_priority" INTEGER,
"started_at" CHARACTER VARYING,
"status" CHARACTER VARYING,
"completed_at" CHARACTER VARYING,
"party_level" INTEGER,
"quest_master" CHARACTER VARYING,
PRIMARY KEY ("quest_id"),
FOREIGN KEY ("main_guild_ref") REFERENCES "guild"("guild_id"),
FOREIGN KEY ("schedule_ref") REFERENCES "guild_schedule"("schedule_id")
);
`;
const result = await fromPostgres(sql);
// Find the guild_quests table
const questTable = result.tables.find(
(t) => t.name === 'guild_quests'
);
expect(questTable).toBeDefined();
// Check specific default values
const activeColumn = questTable?.columns.find(
(c) => c.name === 'is_active'
);
expect(activeColumn?.default).toBe("'active'");
const statusColumn = questTable?.columns.find(
(c) => c.name === 'quest_status'
);
expect(statusColumn?.default).toBe("'pending'");
const completedColumn = questTable?.columns.find(
(c) => c.name === 'is_completed'
);
expect(completedColumn?.default).toBe("'0'");
});
});
describe('ALTER TABLE ADD COLUMN with defaults', () => {
it('should handle ALTER TABLE ADD COLUMN with default values', async () => {
const sql = `
CREATE TABLE adventurers (
adventurer_id INTEGER NOT NULL,
PRIMARY KEY (adventurer_id)
);
ALTER TABLE adventurers ADD COLUMN class_type VARCHAR(50) DEFAULT 'warrior';
ALTER TABLE adventurers ADD COLUMN experience_points INTEGER DEFAULT 0;
ALTER TABLE adventurers ADD COLUMN is_guild_member BOOLEAN DEFAULT TRUE;
ALTER TABLE adventurers ADD COLUMN joined_at TIMESTAMP DEFAULT NOW();
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const classColumn = result.tables[0].columns.find(
(c) => c.name === 'class_type'
);
expect(classColumn?.default).toBe("'warrior'");
const xpColumn = result.tables[0].columns.find(
(c) => c.name === 'experience_points'
);
expect(xpColumn?.default).toBe('0');
const guildColumn = result.tables[0].columns.find(
(c) => c.name === 'is_guild_member'
);
expect(guildColumn?.default).toBe('TRUE');
const joinedColumn = result.tables[0].columns.find(
(c) => c.name === 'joined_at'
);
expect(joinedColumn?.default).toBe('NOW()');
});
});
describe('Edge Cases and Special Characters', () => {
it('should handle defaults with parentheses in strings', async () => {
const sql = `
CREATE TABLE spell_formulas (
formula_id INTEGER NOT NULL,
damage_calculation VARCHAR DEFAULT '(strength + magic) * 2',
mana_cost TEXT DEFAULT 'cast(level * 10 - wisdom)',
PRIMARY KEY (formula_id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const damageColumn = result.tables[0].columns.find(
(c) => c.name === 'damage_calculation'
);
expect(damageColumn?.default).toBe("'(strength + magic) * 2'");
const manaColumn = result.tables[0].columns.find(
(c) => c.name === 'mana_cost'
);
expect(manaColumn?.default).toBe("'cast(level * 10 - wisdom)'");
});
it('should handle defaults with JSON strings', async () => {
const sql = `
CREATE TABLE item_enchantments (
enchantment_id INTEGER NOT NULL,
properties JSON DEFAULT '{"element": "fire"}',
modifiers JSONB DEFAULT '[]',
PRIMARY KEY (enchantment_id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const propertiesColumn = result.tables[0].columns.find(
(c) => c.name === 'properties'
);
expect(propertiesColumn?.default).toBe(`'{"element": "fire"}'`);
const modifiersColumn = result.tables[0].columns.find(
(c) => c.name === 'modifiers'
);
expect(modifiersColumn?.default).toBe("'[]'");
});
it('should handle casting in defaults', async () => {
const sql = `
CREATE TABLE ancient_runes (
rune_id INTEGER NOT NULL,
rune_type VARCHAR DEFAULT 'healing'::text,
PRIMARY KEY (rune_id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const runeColumn = result.tables[0].columns.find(
(c) => c.name === 'rune_type'
);
expect(runeColumn?.default).toBe("'healing'::text");
});
});
describe('Serial Types', () => {
it('should not set default for SERIAL types as they auto-increment', async () => {
const sql = `
CREATE TABLE monster_spawns (
spawn_id SERIAL PRIMARY KEY,
minion_id SMALLSERIAL,
boss_id BIGSERIAL
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const spawnColumn = result.tables[0].columns.find(
(c) => c.name === 'spawn_id'
);
expect(spawnColumn?.default).toBeUndefined();
expect(spawnColumn?.increment).toBe(true);
const minionColumn = result.tables[0].columns.find(
(c) => c.name === 'minion_id'
);
expect(minionColumn?.default).toBeUndefined();
expect(minionColumn?.increment).toBe(true);
const bossColumn = result.tables[0].columns.find(
(c) => c.name === 'boss_id'
);
expect(bossColumn?.default).toBeUndefined();
expect(bossColumn?.increment).toBe(true);
});
});
});

View File

@@ -0,0 +1,350 @@
import { describe, it, expect } from 'vitest';
import { fromPostgres } from '../postgresql';
describe('PostgreSQL Import - Quoted Identifiers with Special Characters', () => {
describe('CREATE TABLE with quoted identifiers', () => {
it('should handle tables with quoted schema and table names', async () => {
const sql = `
CREATE TABLE "my-schema"."user-profiles" (
id serial PRIMARY KEY,
name text NOT NULL
);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(1);
const table = result.tables[0];
expect(table.schema).toBe('my-schema');
expect(table.name).toBe('user-profiles');
});
it('should handle tables with spaces in schema and table names', async () => {
const sql = `
CREATE TABLE "user schema"."profile table" (
"user id" integer PRIMARY KEY,
"full name" varchar(255)
);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(1);
const table = result.tables[0];
expect(table.schema).toBe('user schema');
expect(table.name).toBe('profile table');
expect(table.columns).toBeDefined();
expect(table.columns.length).toBeGreaterThan(0);
// Note: Column names with spaces might be parsed differently
});
it('should handle mixed quoted and unquoted identifiers', async () => {
const sql = `
CREATE TABLE "special-schema".users (
id serial PRIMARY KEY
);
CREATE TABLE public."special-table" (
id serial PRIMARY KEY
);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(2);
expect(result.tables[0].schema).toBe('special-schema');
expect(result.tables[0].name).toBe('users');
expect(result.tables[1].schema).toBe('public');
expect(result.tables[1].name).toBe('special-table');
});
it('should handle tables with dots in names', async () => {
const sql = `
CREATE TABLE "schema.with.dots"."table.with.dots" (
id serial PRIMARY KEY,
data text
);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(1);
const table = result.tables[0];
expect(table.schema).toBe('schema.with.dots');
expect(table.name).toBe('table.with.dots');
});
});
describe('FOREIGN KEY with quoted identifiers', () => {
it('should handle inline REFERENCES with quoted identifiers', async () => {
const sql = `
CREATE TABLE "auth-schema"."users" (
"user-id" serial PRIMARY KEY,
email text UNIQUE
);
CREATE TABLE "app-schema"."user-profiles" (
id serial PRIMARY KEY,
"user-id" integer REFERENCES "auth-schema"."users"("user-id"),
bio text
);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(2);
expect(result.relationships).toHaveLength(1);
const relationship = result.relationships[0];
expect(relationship.sourceTable).toBe('user-profiles');
expect(relationship.targetTable).toBe('users');
expect(relationship.sourceColumn).toBe('user-id');
expect(relationship.targetColumn).toBe('user-id');
});
it('should handle FOREIGN KEY constraints with quoted identifiers', async () => {
const sql = `
CREATE TABLE "schema one"."table one" (
"id field" serial PRIMARY KEY,
"data field" text
);
CREATE TABLE "schema two"."table two" (
id serial PRIMARY KEY,
"ref id" integer,
FOREIGN KEY ("ref id") REFERENCES "schema one"."table one"("id field")
);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(2);
expect(result.relationships).toHaveLength(1);
const relationship = result.relationships[0];
expect(relationship.sourceTable).toBe('table two');
expect(relationship.targetTable).toBe('table one');
expect(relationship.sourceColumn).toBe('ref id');
expect(relationship.targetColumn).toBe('id field');
});
it('should handle named constraints with quoted identifiers', async () => {
const sql = `
CREATE TABLE "auth"."users" (
id serial PRIMARY KEY
);
CREATE TABLE "app"."profiles" (
id serial PRIMARY KEY,
user_id integer,
CONSTRAINT "fk-user-profile" FOREIGN KEY (user_id) REFERENCES "auth"."users"(id)
);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.relationships).toHaveLength(1);
const relationship = result.relationships[0];
// Note: Constraint names with special characters might be normalized
expect(relationship.name).toBeDefined();
});
it('should handle ALTER TABLE ADD CONSTRAINT with quoted identifiers', async () => {
const sql = `
CREATE TABLE "user-schema"."user-accounts" (
"account-id" serial PRIMARY KEY,
username text
);
CREATE TABLE "order-schema"."user-orders" (
"order-id" serial PRIMARY KEY,
"account-id" integer
);
ALTER TABLE "order-schema"."user-orders"
ADD CONSTRAINT "fk_orders_accounts"
FOREIGN KEY ("account-id")
REFERENCES "user-schema"."user-accounts"("account-id");
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(2);
expect(result.relationships).toHaveLength(1);
const relationship = result.relationships[0];
expect(relationship.name).toBe('fk_orders_accounts');
expect(relationship.sourceTable).toBe('user-orders');
expect(relationship.targetTable).toBe('user-accounts');
expect(relationship.sourceColumn).toBe('account-id');
expect(relationship.targetColumn).toBe('account-id');
});
it('should handle complex mixed quoting scenarios', async () => {
const sql = `
CREATE TABLE auth.users (
id serial PRIMARY KEY
);
CREATE TABLE "app-data"."user_profiles" (
profile_id serial PRIMARY KEY,
"user-id" integer REFERENCES auth.users(id)
);
CREATE TABLE "app-data".posts (
id serial PRIMARY KEY,
profile_id integer
);
ALTER TABLE "app-data".posts
ADD CONSTRAINT fk_posts_profiles
FOREIGN KEY (profile_id)
REFERENCES "app-data"."user_profiles"(profile_id);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(3);
expect(result.relationships).toHaveLength(2);
// Verify the relationships were correctly identified
const profilesTable = result.tables.find(
(t) => t.name === 'user_profiles'
);
expect(profilesTable?.schema).toBe('app-data');
const postsTable = result.tables.find((t) => t.name === 'posts');
expect(postsTable?.schema).toBe('app-data');
});
});
describe('Edge cases and special scenarios', () => {
it('should handle Unicode characters in quoted identifiers', async () => {
const sql = `
CREATE TABLE "схема"."таблица" (
"идентификатор" serial PRIMARY KEY,
"данные" text
);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(1);
const table = result.tables[0];
expect(table.schema).toBe('схема');
expect(table.name).toBe('таблица');
expect(table.columns).toBeDefined();
expect(table.columns.length).toBeGreaterThan(0);
});
it('should handle parentheses in quoted identifiers', async () => {
const sql = `
CREATE TABLE "schema(prod)"."users(archived)" (
id serial PRIMARY KEY,
data text
);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(1);
const table = result.tables[0];
expect(table.schema).toBe('schema(prod)');
expect(table.name).toBe('users(archived)');
});
it('should handle forward slashes in quoted identifiers', async () => {
const sql = `
CREATE TABLE "api/v1"."users/profiles" (
id serial PRIMARY KEY
);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(1);
const table = result.tables[0];
expect(table.schema).toBe('api/v1');
expect(table.name).toBe('users/profiles');
});
it('should handle IF NOT EXISTS with quoted identifiers', async () => {
const sql = `
CREATE TABLE IF NOT EXISTS "test-schema"."test-table" (
id serial PRIMARY KEY
);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(1);
const table = result.tables[0];
expect(table.schema).toBe('test-schema');
expect(table.name).toBe('test-table');
});
it('should handle ONLY keyword with quoted identifiers', async () => {
const sql = `
CREATE TABLE ONLY "parent-schema"."parent-table" (
id serial PRIMARY KEY
);
ALTER TABLE ONLY "parent-schema"."parent-table"
ADD CONSTRAINT "unique-constraint" UNIQUE (id);
`;
const result = await fromPostgres(sql);
// ONLY keyword might trigger warnings
expect(result.warnings).toBeDefined();
expect(result.tables).toHaveLength(1);
const table = result.tables[0];
expect(table.schema).toBe('parent-schema');
expect(table.name).toBe('parent-table');
});
it('should handle self-referencing foreign keys with quoted identifiers', async () => {
const sql = `
CREATE TABLE "org-schema"."departments" (
"dept-id" serial PRIMARY KEY,
"parent-dept-id" integer REFERENCES "org-schema"."departments"("dept-id"),
name text
);
`;
const result = await fromPostgres(sql);
expect(result.warnings || []).toHaveLength(0);
expect(result.tables).toHaveLength(1);
expect(result.relationships).toHaveLength(1);
const relationship = result.relationships[0];
expect(relationship.sourceTable).toBe('departments');
expect(relationship.targetTable).toBe('departments'); // Self-reference
expect(relationship.sourceColumn).toBe('parent-dept-id');
expect(relationship.targetColumn).toBe('dept-id');
});
});
});

View File

@@ -91,7 +91,38 @@ export interface AlterTableExprItem {
action: string; action: string;
resource?: string; resource?: string;
type?: string; type?: string;
keyword?: string;
constraint?: { constraint_type?: string }; constraint?: { constraint_type?: string };
// Properties for ADD COLUMN
column?:
| {
column?:
| {
expr?: {
value?: string;
};
}
| string;
}
| string
| ColumnReference;
definition?: {
dataType?: string;
length?: number;
precision?: number;
scale?: number;
suffix?: unknown[];
nullable?: { type: string };
unique?: string;
primary_key?: string;
constraint?: string;
default_val?: unknown;
auto_increment?: string;
};
nullable?: { type: string; value?: string };
unique?: string;
default_val?: unknown;
// Properties for constraints
create_definitions?: create_definitions?:
| AlterTableConstraintDefinition | AlterTableConstraintDefinition
| { | {

View File

@@ -7,6 +7,8 @@ import type {
SQLForeignKey, SQLForeignKey,
SQLEnumType, SQLEnumType,
} from '../../common'; } from '../../common';
import { buildSQLFromAST } from '../../common';
import { DatabaseType } from '@/lib/domain/database-type';
import type { import type {
TableReference, TableReference,
ColumnReference, ColumnReference,
@@ -347,13 +349,20 @@ function extractColumnsFromSQL(sql: string): SQLColumn[] {
// Try to extract column definition // Try to extract column definition
// Match: column_name TYPE[(params)][array] // Match: column_name TYPE[(params)][array]
// Updated regex to handle complex types like GEOGRAPHY(POINT, 4326) and custom types like subscription_status // First extract column name and everything after it
const columnMatch = trimmedLine.match( const columnMatch = trimmedLine.match(/^\s*["']?(\w+)["']?\s+(.+)/i);
/^\s*["']?(\w+)["']?\s+([\w_]+(?:\([^)]+\))?(?:\[\])?)/i
);
if (columnMatch) { if (columnMatch) {
const columnName = columnMatch[1]; const columnName = columnMatch[1];
let columnType = columnMatch[2]; const restOfLine = columnMatch[2];
// Now extract the type from the rest of the line
// Match type which could be multi-word (like CHARACTER VARYING) with optional params
const typeMatch = restOfLine.match(
/^((?:CHARACTER\s+VARYING|DOUBLE\s+PRECISION|[\w]+)(?:\([^)]+\))?(?:\[\])?)/i
);
if (!typeMatch) continue;
let columnType = typeMatch[1].trim();
// Normalize PostGIS types // Normalize PostGIS types
if (columnType.toUpperCase().startsWith('GEOGRAPHY')) { if (columnType.toUpperCase().startsWith('GEOGRAPHY')) {
@@ -380,7 +389,65 @@ function extractColumnsFromSQL(sql: string): SQLColumn[] {
const isPrimary = trimmedLine.match(/PRIMARY\s+KEY/i) !== null; const isPrimary = trimmedLine.match(/PRIMARY\s+KEY/i) !== null;
const isNotNull = trimmedLine.match(/NOT\s+NULL/i) !== null; const isNotNull = trimmedLine.match(/NOT\s+NULL/i) !== null;
const isUnique = trimmedLine.match(/\bUNIQUE\b/i) !== null; const isUnique = trimmedLine.match(/\bUNIQUE\b/i) !== null;
const hasDefault = trimmedLine.match(/DEFAULT\s+/i) !== null;
// Extract default value
let defaultValue: string | undefined;
// Updated regex to handle casting with :: operator
const defaultMatch = trimmedLine.match(
/DEFAULT\s+((?:'[^']*'|"[^"]*"|\S+)(?:::\w+)?)/i
);
if (defaultMatch) {
let defVal = defaultMatch[1].trim();
// Remove trailing comma if present
defVal = defVal.replace(/,$/, '').trim();
// Handle string literals
if (defVal.startsWith("'") && defVal.endsWith("'")) {
// Keep the quotes for string literals
defaultValue = defVal;
} else if (defVal.match(/^\d+(\.\d+)?$/)) {
// Numeric value
defaultValue = defVal;
} else if (
defVal.toUpperCase() === 'TRUE' ||
defVal.toUpperCase() === 'FALSE'
) {
// Boolean value
defaultValue = defVal.toUpperCase();
} else if (defVal.toUpperCase() === 'NULL') {
// NULL value
defaultValue = 'NULL';
} else if (defVal.includes('(') && defVal.includes(')')) {
// Function call (like gen_random_uuid())
// Normalize PostgreSQL function names to uppercase
const funcMatch = defVal.match(/^(\w+)\(/);
if (funcMatch) {
const funcName = funcMatch[1];
const pgFunctions = [
'now',
'current_timestamp',
'current_date',
'current_time',
'gen_random_uuid',
'random',
'nextval',
'currval',
];
if (pgFunctions.includes(funcName.toLowerCase())) {
defaultValue = defVal.replace(
funcName,
funcName.toUpperCase()
);
} else {
defaultValue = defVal;
}
} else {
defaultValue = defVal;
}
} else {
// Other expressions
defaultValue = defVal;
}
}
columns.push({ columns.push({
name: columnName, name: columnName,
@@ -388,7 +455,7 @@ function extractColumnsFromSQL(sql: string): SQLColumn[] {
nullable: !isNotNull && !isPrimary, nullable: !isNotNull && !isPrimary,
primaryKey: isPrimary, primaryKey: isPrimary,
unique: isUnique || isPrimary, unique: isUnique || isPrimary,
default: hasDefault ? 'has default' : undefined, default: defaultValue,
increment: increment:
isSerialType || isSerialType ||
trimmedLine.includes('gen_random_uuid()') || trimmedLine.includes('gen_random_uuid()') ||
@@ -490,16 +557,21 @@ function extractForeignKeysFromCreateTable(
const tableBody = tableBodyMatch[1]; const tableBody = tableBodyMatch[1];
// Pattern for inline REFERENCES - more flexible to handle various formats // Pattern for inline REFERENCES - handles quoted and unquoted identifiers
const inlineRefPattern = const inlineRefPattern =
/["']?(\w+)["']?\s+(?:\w+(?:\([^)]*\))?(?:\[[^\]]*\])?(?:\s+\w+)*\s+)?REFERENCES\s+(?:["']?(\w+)["']?\.)?["']?(\w+)["']?\s*\(\s*["']?(\w+)["']?\s*\)/gi; /(?:"([^"]+)"|([^"\s,()]+))\s+(?:\w+(?:\([^)]*\))?(?:\[[^\]]*\])?(?:\s+\w+)*\s+)?REFERENCES\s+(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))\s*\(\s*(?:"([^"]+)"|([^"\s,)]+))\s*\)/gi;
let match; let match;
while ((match = inlineRefPattern.exec(tableBody)) !== null) { while ((match = inlineRefPattern.exec(tableBody)) !== null) {
const sourceColumn = match[1]; // Extract values from appropriate match groups
const targetSchema = match[2] || 'public'; // Groups: 1=quoted source col, 2=unquoted source col,
const targetTable = match[3]; // 3=quoted schema, 4=unquoted schema,
const targetColumn = match[4]; // 5=quoted target table, 6=unquoted target table,
// 7=quoted target col, 8=unquoted target col
const sourceColumn = match[1] || match[2];
const targetSchema = match[3] || match[4] || 'public';
const targetTable = match[5] || match[6];
const targetColumn = match[7] || match[8];
const targetTableKey = `${targetSchema}.${targetTable}`; const targetTableKey = `${targetSchema}.${targetTable}`;
const targetTableId = tableMap[targetTableKey]; const targetTableId = tableMap[targetTableKey];
@@ -521,15 +593,16 @@ function extractForeignKeysFromCreateTable(
} }
} }
// Pattern for FOREIGN KEY constraints // Pattern for FOREIGN KEY constraints - handles quoted and unquoted identifiers
const fkConstraintPattern = const fkConstraintPattern =
/FOREIGN\s+KEY\s*\(\s*["']?(\w+)["']?\s*\)\s*REFERENCES\s+(?:["']?(\w+)["']?\.)?["']?(\w+)["']?\s*\(\s*["']?(\w+)["']?\s*\)/gi; /FOREIGN\s+KEY\s*\(\s*(?:"([^"]+)"|([^"\s,)]+))\s*\)\s*REFERENCES\s+(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))\s*\(\s*(?:"([^"]+)"|([^"\s,)]+))\s*\)/gi;
while ((match = fkConstraintPattern.exec(tableBody)) !== null) { while ((match = fkConstraintPattern.exec(tableBody)) !== null) {
const sourceColumn = match[1]; // Extract values from appropriate match groups
const targetSchema = match[2] || 'public'; const sourceColumn = match[1] || match[2];
const targetTable = match[3]; const targetSchema = match[3] || match[4] || 'public';
const targetColumn = match[4]; const targetTable = match[5] || match[6];
const targetColumn = match[7] || match[8];
const targetTableKey = `${targetSchema}.${targetTable}`; const targetTableKey = `${targetSchema}.${targetTable}`;
const targetTableId = tableMap[targetTableKey]; const targetTableId = tableMap[targetTableKey];
@@ -585,12 +658,16 @@ export async function fromPostgres(
? stmt.sql.substring(createTableIndex) ? stmt.sql.substring(createTableIndex)
: stmt.sql; : stmt.sql;
// Updated regex to properly handle quoted identifiers with special characters
// Matches: schema.table, "schema"."table", "schema".table, schema."table"
const tableMatch = sqlFromCreate.match( const tableMatch = sqlFromCreate.match(
/CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?(?:\s+ONLY)?\s+(?:"?([^"\s.]+)"?\.)?["'`]?([^"'`\s.(]+)["'`]?/i /CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?(?:\s+ONLY)?\s+(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))/i
); );
if (tableMatch) { if (tableMatch) {
const schemaName = tableMatch[1] || 'public'; // Extract schema and table names from the appropriate match groups
const tableName = tableMatch[2]; // Groups: 1=quoted schema, 2=unquoted schema, 3=quoted table, 4=unquoted table
const schemaName = tableMatch[1] || tableMatch[2] || 'public';
const tableName = tableMatch[3] || tableMatch[4];
const tableKey = `${schemaName}.${tableName}`; const tableKey = `${schemaName}.${tableName}`;
tableMap[tableKey] = generateId(); tableMap[tableKey] = generateId();
} }
@@ -938,12 +1015,16 @@ export async function fromPostgres(
? stmt.sql.substring(createTableIndex) ? stmt.sql.substring(createTableIndex)
: stmt.sql; : stmt.sql;
// Updated regex to properly handle quoted identifiers with special characters
// Matches: schema.table, "schema"."table", "schema".table, schema."table"
const tableMatch = sqlFromCreate.match( const tableMatch = sqlFromCreate.match(
/CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?(?:\s+ONLY)?\s+(?:"?([^"\s.]+)"?\.)?["'`]?([^"'`\s.(]+)["'`]?/i /CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?(?:\s+ONLY)?\s+(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))/i
); );
if (tableMatch) { if (tableMatch) {
const schemaName = tableMatch[1] || 'public'; // Extract schema and table names from the appropriate match groups
const tableName = tableMatch[2]; // Groups: 1=quoted schema, 2=unquoted schema, 3=quoted table, 4=unquoted table
const schemaName = tableMatch[1] || tableMatch[2] || 'public';
const tableName = tableMatch[3] || tableMatch[4];
const tableKey = `${schemaName}.${tableName}`; const tableKey = `${schemaName}.${tableName}`;
const tableId = tableMap[tableKey]; const tableId = tableMap[tableKey];
@@ -982,7 +1063,7 @@ export async function fromPostgres(
} }
} }
// Fourth pass: process ALTER TABLE statements for foreign keys // Fourth pass: process ALTER TABLE statements for foreign keys and ADD COLUMN
for (const stmt of statements) { for (const stmt of statements) {
if (stmt.type === 'alter' && stmt.parsed) { if (stmt.type === 'alter' && stmt.parsed) {
const alterTableStmt = stmt.parsed as AlterTableStatement; const alterTableStmt = stmt.parsed as AlterTableStatement;
@@ -1012,13 +1093,440 @@ export async function fromPostgres(
); );
if (!table) continue; if (!table) continue;
// Process foreign key constraints in ALTER TABLE // Process ALTER TABLE expressions
if (alterTableStmt.expr && Array.isArray(alterTableStmt.expr)) { if (alterTableStmt.expr && Array.isArray(alterTableStmt.expr)) {
alterTableStmt.expr.forEach((expr: AlterTableExprItem) => { alterTableStmt.expr.forEach((expr: AlterTableExprItem) => {
if (expr.action === 'add' && expr.create_definitions) { // Handle ALTER COLUMN TYPE
if (expr.action === 'alter' && expr.resource === 'column') {
// Extract column name
let columnName: string | undefined;
if (
typeof expr.column === 'object' &&
'column' in expr.column
) {
const innerColumn = expr.column.column;
if (
typeof innerColumn === 'object' &&
'expr' in innerColumn &&
innerColumn.expr?.value
) {
columnName = innerColumn.expr.value;
} else if (typeof innerColumn === 'string') {
columnName = innerColumn;
}
} else if (typeof expr.column === 'string') {
columnName = expr.column;
}
// Check if it's a TYPE change
if (
columnName &&
expr.type === 'alter' &&
expr.definition?.dataType
) {
// Find the column in the table and update its type
const column = table.columns.find(
(col) => (col as SQLColumn).name === columnName
);
if (column) {
const definition = expr.definition;
const rawDataType = String(definition.dataType);
// console.log('ALTER TYPE expr:', JSON.stringify(expr, null, 2));
// Normalize the type
let normalizedType =
normalizePostgreSQLType(rawDataType);
// Handle type parameters
if (
definition.scale !== undefined &&
definition.scale !== null
) {
// For NUMERIC/DECIMAL with scale, length is actually precision
const precision =
definition.length ||
definition.precision;
normalizedType = `${normalizedType}(${precision},${definition.scale})`;
} else if (
definition.length !== undefined &&
definition.length !== null
) {
normalizedType = `${normalizedType}(${definition.length})`;
} else if (definition.precision !== undefined) {
normalizedType = `${normalizedType}(${definition.precision})`;
} else if (
definition.suffix &&
Array.isArray(definition.suffix) &&
definition.suffix.length > 0
) {
const params = definition.suffix
.map((s: unknown) => {
if (
typeof s === 'object' &&
s !== null &&
'value' in s
) {
return String(s.value);
}
return String(s);
})
.join(',');
normalizedType = `${normalizedType}(${params})`;
}
// Update the column type
(column as SQLColumn).type = normalizedType;
// Update typeArgs if applicable
if (
definition.scale !== undefined &&
definition.scale !== null
) {
// For NUMERIC/DECIMAL with scale
const precision =
definition.length ||
definition.precision;
(column as SQLColumn).typeArgs = {
precision: precision,
scale: definition.scale,
};
} else if (definition.length) {
(column as SQLColumn).typeArgs = {
length: definition.length,
};
} else if (definition.precision) {
(column as SQLColumn).typeArgs = {
precision: definition.precision,
};
}
}
}
// Handle ADD COLUMN
} else if (
expr.action === 'add' &&
expr.resource === 'column'
) {
// Handle ADD COLUMN directly from expr structure
// Extract column name from the nested structure
let columnName: string | undefined;
if (
typeof expr.column === 'object' &&
'column' in expr.column
) {
const innerColumn = expr.column.column;
if (
typeof innerColumn === 'object' &&
'expr' in innerColumn &&
innerColumn.expr?.value
) {
columnName = innerColumn.expr.value;
} else if (typeof innerColumn === 'string') {
columnName = innerColumn;
}
} else if (typeof expr.column === 'string') {
columnName = expr.column;
}
if (columnName && typeof columnName === 'string') {
const definition = expr.definition || {};
const rawDataType = String(
definition?.dataType || 'TEXT'
);
// console.log('expr:', JSON.stringify(expr, null, 2));
// Normalize the type
let normalizedBaseType =
normalizePostgreSQLType(rawDataType);
// Check if it's a serial type
const upperType = rawDataType.toUpperCase();
const isSerialType = [
'SERIAL',
'SERIAL2',
'SERIAL4',
'SERIAL8',
'BIGSERIAL',
'SMALLSERIAL',
].includes(upperType.split('(')[0]);
if (isSerialType) {
const typeLength = definition?.length as
| number
| undefined;
if (upperType === 'SERIAL') {
if (typeLength === 2) {
normalizedBaseType = 'SMALLINT';
} else if (typeLength === 8) {
normalizedBaseType = 'BIGINT';
} else {
normalizedBaseType = 'INTEGER';
}
}
}
// Handle type parameters
let finalDataType = normalizedBaseType;
const isNormalizedIntegerType =
['INTEGER', 'BIGINT', 'SMALLINT'].includes(
normalizedBaseType
) &&
(upperType === 'INT' || upperType === 'SERIAL');
if (!isSerialType && !isNormalizedIntegerType) {
const precision = definition?.precision;
const scale = definition?.scale;
const length = definition?.length;
const suffix =
(definition?.suffix as unknown[]) || [];
if (suffix.length > 0) {
const params = suffix
.map((s: unknown) => {
if (
typeof s === 'object' &&
s !== null &&
'value' in s
) {
return String(
(s as { value: unknown })
.value
);
}
return String(s);
})
.join(',');
finalDataType = `${normalizedBaseType}(${params})`;
} else if (precision !== undefined) {
if (scale !== undefined) {
finalDataType = `${normalizedBaseType}(${precision},${scale})`;
} else {
finalDataType = `${normalizedBaseType}(${precision})`;
}
} else if (
length !== undefined &&
length !== null
) {
finalDataType = `${normalizedBaseType}(${length})`;
}
}
// Check for nullable constraint
let nullable = true;
if (isSerialType) {
nullable = false;
} else if (
expr.nullable &&
expr.nullable.type === 'not null'
) {
nullable = false;
} else if (
definition?.nullable &&
definition.nullable.type === 'not null'
) {
nullable = false;
}
// Check for unique constraint
const isUnique =
expr.unique === 'unique' ||
definition?.unique === 'unique';
// Check for default value
let defaultValue: string | undefined;
const defaultVal =
expr.default_val || definition?.default_val;
if (defaultVal && !isSerialType) {
// Create a temporary columnDef to use the getDefaultValueString function
const tempColumnDef = {
default_val: defaultVal,
} as ColumnDefinition;
defaultValue =
getDefaultValueString(tempColumnDef);
}
// Create the new column object
const newColumn: SQLColumn = {
name: columnName,
type: finalDataType,
nullable: nullable,
primaryKey:
definition?.primary_key === 'primary key' ||
definition?.constraint === 'primary key' ||
isSerialType,
unique: isUnique,
default: defaultValue,
increment:
isSerialType ||
definition?.auto_increment ===
'auto_increment' ||
(stmt.sql
.toUpperCase()
.includes('GENERATED') &&
stmt.sql
.toUpperCase()
.includes('IDENTITY')),
};
// Add the column to the table if it doesn't already exist
const tableColumns = table.columns as SQLColumn[];
if (
!tableColumns.some(
(col) => col.name === columnName
)
) {
tableColumns.push(newColumn);
}
}
} else if (
expr.action === 'add' &&
expr.create_definitions
) {
const createDefs = expr.create_definitions; const createDefs = expr.create_definitions;
if ( // Check if it's adding a column (legacy structure)
if (createDefs.resource === 'column') {
const columnDef =
createDefs as unknown as ColumnDefinition;
const columnName = extractColumnName(
columnDef.column
);
if (columnName) {
// Extract the column type and properties
const definition =
columnDef.definition as Record<
string,
unknown
>;
const rawDataType = String(
definition?.dataType || 'TEXT'
);
// Normalize the type
let normalizedBaseType =
normalizePostgreSQLType(rawDataType);
// Check if it's a serial type
const upperType = rawDataType.toUpperCase();
const isSerialType = [
'SERIAL',
'SERIAL2',
'SERIAL4',
'SERIAL8',
'BIGSERIAL',
'SMALLSERIAL',
].includes(upperType.split('(')[0]);
if (isSerialType) {
const typeLength = definition?.length as
| number
| undefined;
if (upperType === 'SERIAL') {
if (typeLength === 2) {
normalizedBaseType = 'SMALLINT';
} else if (typeLength === 8) {
normalizedBaseType = 'BIGINT';
} else {
normalizedBaseType = 'INTEGER';
}
}
}
// Handle type parameters
let finalDataType = normalizedBaseType;
const isNormalizedIntegerType =
['INTEGER', 'BIGINT', 'SMALLINT'].includes(
normalizedBaseType
) &&
(upperType === 'INT' ||
upperType === 'SERIAL');
if (!isSerialType && !isNormalizedIntegerType) {
const precision =
columnDef.definition?.precision;
const scale = columnDef.definition?.scale;
const length = columnDef.definition?.length;
const suffix =
(definition?.suffix as unknown[]) || [];
if (suffix.length > 0) {
const params = suffix
.map((s: unknown) => {
if (
typeof s === 'object' &&
s !== null &&
'value' in s
) {
return String(
(
s as {
value: unknown;
}
).value
);
}
return String(s);
})
.join(',');
finalDataType = `${normalizedBaseType}(${params})`;
} else if (precision !== undefined) {
if (scale !== undefined) {
finalDataType = `${normalizedBaseType}(${precision},${scale})`;
} else {
finalDataType = `${normalizedBaseType}(${precision})`;
}
} else if (
length !== undefined &&
length !== null
) {
finalDataType = `${normalizedBaseType}(${length})`;
}
}
// Create the new column object
const newColumn: SQLColumn = {
name: columnName,
type: finalDataType,
nullable: isSerialType
? false
: columnDef.nullable?.type !==
'not null',
primaryKey:
columnDef.primary_key ===
'primary key' ||
columnDef.definition?.constraint ===
'primary key' ||
isSerialType,
unique: columnDef.unique === 'unique',
typeArgs: getTypeArgs(columnDef.definition),
default: isSerialType
? undefined
: getDefaultValueString(columnDef),
increment:
isSerialType ||
columnDef.auto_increment ===
'auto_increment' ||
(stmt.sql
.toUpperCase()
.includes('GENERATED') &&
stmt.sql
.toUpperCase()
.includes('IDENTITY')),
};
// Add the column to the table if it doesn't already exist
const tableColumns2 =
table.columns as SQLColumn[];
if (
!tableColumns2.some(
(col) => col.name === columnName
)
) {
tableColumns2.push(newColumn);
}
}
} else if (
createDefs.constraint_type === 'FOREIGN KEY' || createDefs.constraint_type === 'FOREIGN KEY' ||
createDefs.constraint_type === 'foreign key' createDefs.constraint_type === 'foreign key'
) { ) {
@@ -1129,19 +1637,188 @@ export async function fromPostgres(
} }
} else if (stmt.type === 'alter' && !stmt.parsed) { } else if (stmt.type === 'alter' && !stmt.parsed) {
// Handle ALTER TABLE statements that failed to parse // Handle ALTER TABLE statements that failed to parse
// First try to extract ALTER COLUMN TYPE statements
const alterTypeMatch = stmt.sql.match(
/ALTER\s+TABLE\s+(?:ONLY\s+)?(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))\s+ALTER\s+COLUMN\s+(?:"([^"]+)"|([^"\s]+))\s+TYPE\s+([\w_]+(?:\([^)]*\))?(?:\[\])?)/i
);
if (alterTypeMatch) {
const schemaName =
alterTypeMatch[1] || alterTypeMatch[2] || 'public';
const tableName = alterTypeMatch[3] || alterTypeMatch[4];
const columnName = alterTypeMatch[5] || alterTypeMatch[6];
let columnType = alterTypeMatch[7];
const table = findTableWithSchemaSupport(
tables,
tableName,
schemaName
);
if (table && columnName) {
const column = (table.columns as SQLColumn[]).find(
(col) => col.name === columnName
);
if (column) {
// Normalize and update the type
columnType = normalizePostgreSQLType(columnType);
column.type = columnType;
// Extract and update typeArgs if present
const typeMatch = columnType.match(
/^(\w+)(?:\(([^)]+)\))?$/
);
if (typeMatch && typeMatch[2]) {
const params = typeMatch[2]
.split(',')
.map((p) => p.trim());
if (params.length === 1) {
column.typeArgs = {
length: parseInt(params[0]),
};
} else if (params.length === 2) {
column.typeArgs = {
precision: parseInt(params[0]),
scale: parseInt(params[1]),
};
}
}
}
}
}
// Then try to extract ADD COLUMN statements
const alterColumnMatch = stmt.sql.match(
/ALTER\s+TABLE\s+(?:ONLY\s+)?(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))\s+ADD\s+COLUMN\s+(?:"([^"]+)"|([^"\s]+))\s+([\w_]+(?:\([^)]*\))?(?:\[\])?)/i
);
if (alterColumnMatch) {
const schemaName =
alterColumnMatch[1] || alterColumnMatch[2] || 'public';
const tableName = alterColumnMatch[3] || alterColumnMatch[4];
const columnName = alterColumnMatch[5] || alterColumnMatch[6];
let columnType = alterColumnMatch[7];
const table = findTableWithSchemaSupport(
tables,
tableName,
schemaName
);
if (table && columnName) {
const tableColumns = table.columns as SQLColumn[];
if (!tableColumns.some((col) => col.name === columnName)) {
// Normalize the type
columnType = normalizePostgreSQLType(columnType);
// Check for constraints in the statement
const columnDefPart = stmt.sql.substring(
stmt.sql.indexOf(columnName)
);
const isPrimary =
columnDefPart.match(/PRIMARY\s+KEY/i) !== null;
const isNotNull =
columnDefPart.match(/NOT\s+NULL/i) !== null;
const isUnique =
columnDefPart.match(/\bUNIQUE\b/i) !== null;
// Extract default value
let defaultValue: string | undefined;
// Updated regex to handle casting with :: operator
const defaultMatch = columnDefPart.match(
/DEFAULT\s+((?:'[^']*'|"[^"]*"|\S+)(?:::\w+)?)/i
);
if (defaultMatch) {
let defVal = defaultMatch[1].trim();
// Remove trailing comma or semicolon if present
defVal = defVal.replace(/[,;]$/, '').trim();
// Handle string literals
if (
defVal.startsWith("'") &&
defVal.endsWith("'")
) {
// Keep the quotes for string literals
defaultValue = defVal;
} else if (defVal.match(/^\d+(\.\d+)?$/)) {
// Numeric value
defaultValue = defVal;
} else if (
defVal.toUpperCase() === 'TRUE' ||
defVal.toUpperCase() === 'FALSE'
) {
// Boolean value
defaultValue = defVal.toUpperCase();
} else if (defVal.toUpperCase() === 'NULL') {
// NULL value
defaultValue = 'NULL';
} else if (
defVal.includes('(') &&
defVal.includes(')')
) {
// Function call
// Normalize PostgreSQL function names to uppercase
const funcMatch = defVal.match(/^(\w+)\(/);
if (funcMatch) {
const funcName = funcMatch[1];
const pgFunctions = [
'now',
'current_timestamp',
'current_date',
'current_time',
'gen_random_uuid',
'random',
'nextval',
'currval',
];
if (
pgFunctions.includes(
funcName.toLowerCase()
)
) {
defaultValue = defVal.replace(
funcName,
funcName.toUpperCase()
);
} else {
defaultValue = defVal;
}
} else {
defaultValue = defVal;
}
} else {
// Other expressions
defaultValue = defVal;
}
}
tableColumns.push({
name: columnName,
type: columnType,
nullable: !isNotNull && !isPrimary,
primaryKey: isPrimary,
unique: isUnique || isPrimary,
default: defaultValue,
increment: false,
});
}
}
}
// Extract foreign keys using regex as fallback // Extract foreign keys using regex as fallback
// Updated regex to handle quoted identifiers properly
const alterFKMatch = stmt.sql.match( const alterFKMatch = stmt.sql.match(
/ALTER\s+TABLE\s+(?:ONLY\s+)?(?:"?([^"\s.]+)"?\.)?["']?([^"'\s.(]+)["']?\s+ADD\s+CONSTRAINT\s+["']?([^"'\s]+)["']?\s+FOREIGN\s+KEY\s*\(["']?([^"'\s)]+)["']?\)\s+REFERENCES\s+(?:"?([^"\s.]+)"?\.)?["']?([^"'\s.(]+)["']?\s*\(["']?([^"'\s)]+)["']?\)/i /ALTER\s+TABLE\s+(?:ONLY\s+)?(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))\s+ADD\s+CONSTRAINT\s+(?:"([^"]+)"|([^"\s]+))\s+FOREIGN\s+KEY\s*\((?:"([^"]+)"|([^"\s)]+))\)\s+REFERENCES\s+(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))\s*\((?:"([^"]+)"|([^"\s)]+))\)/i
); );
if (alterFKMatch) { if (alterFKMatch) {
const sourceSchema = alterFKMatch[1] || 'public'; // Extract values from appropriate match groups
const sourceTable = alterFKMatch[2]; const sourceSchema =
const constraintName = alterFKMatch[3]; alterFKMatch[1] || alterFKMatch[2] || 'public';
const sourceColumn = alterFKMatch[4]; const sourceTable = alterFKMatch[3] || alterFKMatch[4];
const targetSchema = alterFKMatch[5] || 'public'; const constraintName = alterFKMatch[5] || alterFKMatch[6];
const targetTable = alterFKMatch[6]; const sourceColumn = alterFKMatch[7] || alterFKMatch[8];
const targetColumn = alterFKMatch[7]; const targetSchema =
alterFKMatch[9] || alterFKMatch[10] || 'public';
const targetTable = alterFKMatch[11] || alterFKMatch[12];
const targetColumn = alterFKMatch[13] || alterFKMatch[14];
const sourceTableId = getTableIdWithSchemaSupport( const sourceTableId = getTableIdWithSchemaSupport(
tableMap, tableMap,
@@ -1275,58 +1952,10 @@ export async function fromPostgres(
function getDefaultValueString( function getDefaultValueString(
columnDef: ColumnDefinition columnDef: ColumnDefinition
): string | undefined { ): string | undefined {
let defVal = columnDef.default_val; const defVal = columnDef.default_val;
if (
defVal &&
typeof defVal === 'object' &&
defVal.type === 'default' &&
'value' in defVal
) {
defVal = defVal.value;
}
if (defVal === undefined || defVal === null) return undefined; if (defVal === undefined || defVal === null) return undefined;
let value: string | undefined; // Use buildSQLFromAST to reconstruct the default value
return buildSQLFromAST(defVal, DatabaseType.POSTGRESQL);
switch (typeof defVal) {
case 'string':
value = defVal;
break;
case 'number':
value = String(defVal);
break;
case 'boolean':
value = defVal ? 'TRUE' : 'FALSE';
break;
case 'object':
if ('value' in defVal && typeof defVal.value === 'string') {
value = defVal.value;
} else if ('raw' in defVal && typeof defVal.raw === 'string') {
value = defVal.raw;
} else if (defVal.type === 'bool') {
value = defVal.value ? 'TRUE' : 'FALSE';
} else if (defVal.type === 'function' && defVal.name) {
const fnName = defVal.name;
if (
fnName &&
typeof fnName === 'object' &&
Array.isArray(fnName.name) &&
fnName.name.length > 0 &&
fnName.name[0].value
) {
value = fnName.name[0].value.toUpperCase();
} else if (typeof fnName === 'string') {
value = fnName.toUpperCase();
} else {
value = 'UNKNOWN_FUNCTION';
}
}
break;
default:
value = undefined;
}
return value;
} }

View File

@@ -0,0 +1,252 @@
import { describe, it, expect } from 'vitest';
import { fromSQLServer } from '../sqlserver';
describe('SQL Server Default Value Import', () => {
describe('String Default Values', () => {
it('should parse simple string defaults with single quotes', async () => {
const sql = `
CREATE TABLE kingdom_citizens (
citizen_id INT NOT NULL,
allegiance NVARCHAR(50) DEFAULT 'neutral',
PRIMARY KEY (citizen_id)
);
`;
const result = await fromSQLServer(sql);
expect(result.tables).toHaveLength(1);
const allegianceColumn = result.tables[0].columns.find(
(c) => c.name === 'allegiance'
);
expect(allegianceColumn?.default).toBe("'neutral'");
});
it('should parse string defaults with Unicode prefix', async () => {
const sql = `
CREATE TABLE ancient_scrolls (
scroll_id INT NOT NULL,
runic_inscription NVARCHAR(255) DEFAULT N'Ancient wisdom',
prophecy NVARCHAR(MAX) DEFAULT N'The chosen one shall rise',
PRIMARY KEY (scroll_id)
);
`;
const result = await fromSQLServer(sql);
expect(result.tables).toHaveLength(1);
const runicColumn = result.tables[0].columns.find(
(c) => c.name === 'runic_inscription'
);
expect(runicColumn?.default).toBe("N'Ancient wisdom'");
const prophecyColumn = result.tables[0].columns.find(
(c) => c.name === 'prophecy'
);
expect(prophecyColumn?.default).toBe(
"N'The chosen one shall rise'"
);
});
});
describe('Numeric Default Values', () => {
it('should parse integer defaults', async () => {
const sql = `
CREATE TABLE castle_treasury (
treasury_id INT NOT NULL,
gold_count INT DEFAULT 0,
max_capacity BIGINT DEFAULT 100000,
guard_posts SMALLINT DEFAULT 5,
PRIMARY KEY (treasury_id)
);
`;
const result = await fromSQLServer(sql);
expect(result.tables).toHaveLength(1);
const goldColumn = result.tables[0].columns.find(
(c) => c.name === 'gold_count'
);
expect(goldColumn?.default).toBe('0');
const capacityColumn = result.tables[0].columns.find(
(c) => c.name === 'max_capacity'
);
expect(capacityColumn?.default).toBe('100000');
const guardColumn = result.tables[0].columns.find(
(c) => c.name === 'guard_posts'
);
expect(guardColumn?.default).toBe('5');
});
it('should parse decimal defaults', async () => {
const sql = `
CREATE TABLE blacksmith_shop (
item_id INT NOT NULL,
weapon_price DECIMAL(10, 2) DEFAULT 99.99,
guild_discount FLOAT DEFAULT 0.15,
enchantment_tax NUMERIC(5, 4) DEFAULT 0.0825,
PRIMARY KEY (item_id)
);
`;
const result = await fromSQLServer(sql);
expect(result.tables).toHaveLength(1);
const priceColumn = result.tables[0].columns.find(
(c) => c.name === 'weapon_price'
);
expect(priceColumn?.default).toBe('99.99');
const discountColumn = result.tables[0].columns.find(
(c) => c.name === 'guild_discount'
);
expect(discountColumn?.default).toBe('0.15');
const taxColumn = result.tables[0].columns.find(
(c) => c.name === 'enchantment_tax'
);
expect(taxColumn?.default).toBe('0.0825');
});
});
describe('Boolean Default Values', () => {
it('should parse BIT defaults', async () => {
const sql = `
CREATE TABLE magic_barriers (
barrier_id INT NOT NULL,
is_active BIT DEFAULT 1,
is_breached BIT DEFAULT 0,
PRIMARY KEY (barrier_id)
);
`;
const result = await fromSQLServer(sql);
expect(result.tables).toHaveLength(1);
const activeColumn = result.tables[0].columns.find(
(c) => c.name === 'is_active'
);
expect(activeColumn?.default).toBe('1');
const breachedColumn = result.tables[0].columns.find(
(c) => c.name === 'is_breached'
);
expect(breachedColumn?.default).toBe('0');
});
});
describe('Date and Time Default Values', () => {
it('should parse date/time function defaults', async () => {
const sql = `
CREATE TABLE battle_logs (
battle_id INT NOT NULL,
battle_started DATETIME DEFAULT GETDATE(),
last_action DATETIME2 DEFAULT SYSDATETIME(),
battle_date DATE DEFAULT GETDATE(),
PRIMARY KEY (battle_id)
);
`;
const result = await fromSQLServer(sql);
expect(result.tables).toHaveLength(1);
const startedColumn = result.tables[0].columns.find(
(c) => c.name === 'battle_started'
);
expect(startedColumn?.default).toBe('GETDATE()');
const actionColumn = result.tables[0].columns.find(
(c) => c.name === 'last_action'
);
expect(actionColumn?.default).toBe('SYSDATETIME()');
const dateColumn = result.tables[0].columns.find(
(c) => c.name === 'battle_date'
);
expect(dateColumn?.default).toBe('GETDATE()');
});
});
describe('IDENTITY columns', () => {
it('should handle IDENTITY columns correctly', async () => {
const sql = `
CREATE TABLE legendary_weapons (
weapon_id INT IDENTITY(1,1) NOT NULL,
legacy_id BIGINT IDENTITY(100,10) NOT NULL,
weapon_name NVARCHAR(100),
PRIMARY KEY (weapon_id)
);
`;
const result = await fromSQLServer(sql);
expect(result.tables).toHaveLength(1);
const weaponColumn = result.tables[0].columns.find(
(c) => c.name === 'weapon_id'
);
expect(weaponColumn?.increment).toBe(true);
const legacyColumn = result.tables[0].columns.find(
(c) => c.name === 'legacy_id'
);
expect(legacyColumn?.increment).toBe(true);
});
});
describe('Complex Real-World Example with Schema', () => {
it('should handle complex table with schema and multiple default types', async () => {
const sql = `
CREATE TABLE [dbo].[QuestContracts] (
[ContractID] INT IDENTITY(1,1) NOT NULL,
[AdventurerID] INT NOT NULL,
[QuestDate] DATETIME DEFAULT GETDATE(),
[QuestStatus] NVARCHAR(20) DEFAULT N'Available',
[RewardAmount] DECIMAL(10, 2) DEFAULT 0.00,
[IsCompleted] BIT DEFAULT 0,
[CompletedDate] DATETIME NULL,
[QuestNotes] NVARCHAR(MAX) DEFAULT NULL,
[DifficultyLevel] INT DEFAULT 5,
[QuestGuid] UNIQUEIDENTIFIER DEFAULT NEWID(),
PRIMARY KEY ([ContractID])
);
`;
const result = await fromSQLServer(sql);
const table = result.tables[0];
expect(table).toBeDefined();
expect(table.schema).toBe('dbo');
// Check various default values
const questDateColumn = table.columns.find(
(c) => c.name === 'QuestDate'
);
expect(questDateColumn?.default).toBe('GETDATE()');
const statusColumn = table.columns.find(
(c) => c.name === 'QuestStatus'
);
expect(statusColumn?.default).toBe("N'Available'");
const rewardColumn = table.columns.find(
(c) => c.name === 'RewardAmount'
);
expect(rewardColumn?.default).toBe('0.00');
const completedColumn = table.columns.find(
(c) => c.name === 'IsCompleted'
);
expect(completedColumn?.default).toBe('0');
const difficultyColumn = table.columns.find(
(c) => c.name === 'DifficultyLevel'
);
expect(difficultyColumn?.default).toBe('5');
const guidColumn = table.columns.find(
(c) => c.name === 'QuestGuid'
);
expect(guidColumn?.default).toBe('NEWID()');
});
});
describe('Expressions in defaults', () => {
it('should handle parentheses in default expressions', async () => {
const sql = `
CREATE TABLE spell_calculations (
calculation_id INT NOT NULL,
base_damage INT DEFAULT (10 + 5),
total_power DECIMAL(10,2) DEFAULT ((100.0 * 0.15) + 10),
PRIMARY KEY (calculation_id)
);
`;
const result = await fromSQLServer(sql);
expect(result.tables).toHaveLength(1);
const damageColumn = result.tables[0].columns.find(
(c) => c.name === 'base_damage'
);
expect(damageColumn?.default).toBe('(10 + 5)');
const powerColumn = result.tables[0].columns.find(
(c) => c.name === 'total_power'
);
expect(powerColumn?.default).toBe('((100.0 * 0.15) + 10)');
});
});
});

View File

@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { fromSQLServer } from '../sqlserver';
describe('SQL Server Complex Fantasy Case', () => {
it('should parse complex SQL with SpellDefinition and SpellComponent tables', async () => {
// Complex SQL with same structure as user's case but fantasy-themed
const sql = `CREATE TABLE [DBO].[SpellDefinition](
[SPELLID] (VARCHAR)(32),
[HASVERBALCOMP] BOOLEAN,
[INCANTATION] [VARCHAR](128),
[INCANTATIONFIX] BOOLEAN,
[ITSCOMPONENTREL] [VARCHAR](32), FOREIGN KEY (itscomponentrel) REFERENCES SpellComponent(SPELLID),
[SHOWVISUALS] BOOLEAN, ) ON [PRIMARY]
CREATE TABLE [DBO].[SpellComponent](
[ALIAS] CHAR (50),
[SPELLID] (VARCHAR)(32),
[ISOPTIONAL] BOOLEAN,
[ITSPARENTCOMP] [VARCHAR](32), FOREIGN KEY (itsparentcomp) REFERENCES SpellComponent(SPELLID),
[ITSSCHOOLMETA] [VARCHAR](32), FOREIGN KEY (itsschoolmeta) REFERENCES MagicSchool(SCHOOLID),
[KEYATTR] CHAR (100), ) ON [PRIMARY]`;
console.log('Testing complex fantasy SQL...');
console.log(
'Number of CREATE TABLE statements:',
(sql.match(/CREATE\s+TABLE/gi) || []).length
);
const result = await fromSQLServer(sql);
console.log(
'Result tables:',
result.tables.map((t) => t.name)
);
console.log('Result relationships:', result.relationships.length);
// Debug: Show actual relationships
if (result.relationships.length === 0) {
console.log('WARNING: No relationships found!');
} else {
console.log('Relationships found:');
result.relationships.forEach((r) => {
console.log(
` ${r.sourceTable}.${r.sourceColumn} -> ${r.targetTable}.${r.targetColumn}`
);
});
}
// Should create TWO tables
expect(result.tables).toHaveLength(2);
// Check first table
const spellDef = result.tables.find(
(t) => t.name === 'SpellDefinition'
);
expect(spellDef).toBeDefined();
expect(spellDef?.schema).toBe('DBO');
expect(spellDef?.columns).toHaveLength(6);
// Check second table
const spellComp = result.tables.find(
(t) => t.name === 'SpellComponent'
);
expect(spellComp).toBeDefined();
expect(spellComp?.schema).toBe('DBO');
expect(spellComp?.columns).toHaveLength(6);
// Check foreign key relationships (should have at least 2)
expect(result.relationships.length).toBeGreaterThanOrEqual(2);
// Check FK from SpellDefinition to SpellComponent
const fkDefToComp = result.relationships.find(
(r) =>
r.sourceTable === 'SpellDefinition' &&
r.targetTable === 'SpellComponent' &&
r.sourceColumn === 'itscomponentrel'
);
expect(fkDefToComp).toBeDefined();
expect(fkDefToComp?.targetColumn).toBe('SPELLID');
// Check self-referential FK in SpellComponent
const selfRefFK = result.relationships.find(
(r) =>
r.sourceTable === 'SpellComponent' &&
r.targetTable === 'SpellComponent' &&
r.sourceColumn === 'itsparentcomp'
);
expect(selfRefFK).toBeDefined();
expect(selfRefFK?.targetColumn).toBe('SPELLID');
});
});

View File

@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import { sqlImportToDiagram } from '../../../index';
import { DatabaseType } from '@/lib/domain/database-type';
describe('SQL Server Full Import Flow', () => {
it('should create relationships when importing through the full flow', async () => {
const sql = `CREATE TABLE [DBO].[SpellDefinition](
[SPELLID] (VARCHAR)(32),
[HASVERBALCOMP] BOOLEAN,
[INCANTATION] [VARCHAR](128),
[INCANTATIONFIX] BOOLEAN,
[ITSCOMPONENTREL] [VARCHAR](32), FOREIGN KEY (itscomponentrel) REFERENCES SpellComponent(SPELLID),
[SHOWVISUALS] BOOLEAN, ) ON [PRIMARY]
CREATE TABLE [DBO].[SpellComponent](
[ALIAS] CHAR (50),
[SPELLID] (VARCHAR)(32),
[ISOPTIONAL] BOOLEAN,
[ITSPARENTCOMP] [VARCHAR](32), FOREIGN KEY (itsparentcomp) REFERENCES SpellComponent(SPELLID),
[ITSSCHOOLMETA] [VARCHAR](32), FOREIGN KEY (itsschoolmeta) REFERENCES MagicSchool(SCHOOLID),
[KEYATTR] CHAR (100), ) ON [PRIMARY]`;
// Test the full import flow as the application uses it
const diagram = await sqlImportToDiagram({
sqlContent: sql,
sourceDatabaseType: DatabaseType.SQL_SERVER,
targetDatabaseType: DatabaseType.SQL_SERVER,
});
// Verify tables
expect(diagram.tables).toHaveLength(2);
const tableNames = diagram.tables?.map((t) => t.name).sort();
expect(tableNames).toEqual(['SpellComponent', 'SpellDefinition']);
// Verify relationships are created in the diagram
expect(diagram.relationships).toBeDefined();
expect(diagram.relationships?.length).toBeGreaterThanOrEqual(2);
// Check specific relationships
const fk1 = diagram.relationships?.find(
(r) =>
r.sourceFieldId &&
r.targetFieldId && // Must have field IDs
diagram.tables?.some(
(t) =>
t.id === r.sourceTableId && t.name === 'SpellDefinition'
)
);
expect(fk1).toBeDefined();
const fk2 = diagram.relationships?.find(
(r) =>
r.sourceFieldId &&
r.targetFieldId && // Must have field IDs
diagram.tables?.some(
(t) =>
t.id === r.sourceTableId &&
t.name === 'SpellComponent' &&
t.id === r.targetTableId // self-reference
)
);
expect(fk2).toBeDefined();
console.log(
'Full flow test - Relationships created:',
diagram.relationships?.length
);
diagram.relationships?.forEach((r) => {
const sourceTable = diagram.tables?.find(
(t) => t.id === r.sourceTableId
);
const targetTable = diagram.tables?.find(
(t) => t.id === r.targetTableId
);
const sourceField = sourceTable?.fields.find(
(f) => f.id === r.sourceFieldId
);
const targetField = targetTable?.fields.find(
(f) => f.id === r.targetFieldId
);
console.log(
` ${sourceTable?.name}.${sourceField?.name} -> ${targetTable?.name}.${targetField?.name}`
);
});
});
it('should handle case-insensitive field matching', async () => {
const sql = `CREATE TABLE DragonLair (
[LAIRID] INT PRIMARY KEY,
[parentLairId] INT, FOREIGN KEY (PARENTLAIRID) REFERENCES DragonLair(lairid)
)`;
const diagram = await sqlImportToDiagram({
sqlContent: sql,
sourceDatabaseType: DatabaseType.SQL_SERVER,
targetDatabaseType: DatabaseType.SQL_SERVER,
});
// Should create the self-referential relationship despite case differences
expect(diagram.relationships?.length).toBe(1);
});
});

View File

@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import { fromSQLServer } from '../sqlserver';
describe('SQL Server Multiple Tables with Foreign Keys', () => {
it('should parse multiple tables with foreign keys in user format', async () => {
const sql = `
CREATE TABLE [DBO].[QuestReward](
[BOID] (VARCHAR)(32),
[HASEXTRACOL] BOOLEAN,
[REWARDCODE] [VARCHAR](128),
[REWARDFIX] BOOLEAN,
[ITSQUESTREL] [VARCHAR](32), FOREIGN KEY (itsquestrel) REFERENCES QuestRelation(BOID),
[SHOWDETAILS] BOOLEAN,
) ON [PRIMARY]
CREATE TABLE [DBO].[QuestRelation](
[ALIAS] CHAR (50),
[BOID] (VARCHAR)(32),
[ISOPTIONAL] BOOLEAN,
[ITSPARENTREL] [VARCHAR](32), FOREIGN KEY (itsparentrel) REFERENCES QuestRelation(BOID),
[ITSGUILDMETA] [VARCHAR](32), FOREIGN KEY (itsguildmeta) REFERENCES GuildMeta(BOID),
[KEYATTR] CHAR (100),
) ON [PRIMARY]
`;
const result = await fromSQLServer(sql);
// Should create both tables
expect(result.tables).toHaveLength(2);
// Check first table
const questReward = result.tables.find((t) => t.name === 'QuestReward');
expect(questReward).toBeDefined();
expect(questReward?.schema).toBe('DBO');
expect(questReward?.columns).toHaveLength(6);
// Check second table
const questRelation = result.tables.find(
(t) => t.name === 'QuestRelation'
);
expect(questRelation).toBeDefined();
expect(questRelation?.schema).toBe('DBO');
expect(questRelation?.columns).toHaveLength(6);
// Check foreign key relationships
expect(result.relationships).toHaveLength(2); // Should have 2 FKs (one self-referential in QuestRelation, one from QuestReward to QuestRelation)
// Check FK from QuestReward to QuestRelation
const fkToRelation = result.relationships.find(
(r) =>
r.sourceTable === 'QuestReward' &&
r.targetTable === 'QuestRelation'
);
expect(fkToRelation).toBeDefined();
expect(fkToRelation?.sourceColumn).toBe('itsquestrel');
expect(fkToRelation?.targetColumn).toBe('BOID');
// Check self-referential FK in QuestRelation
const selfRefFK = result.relationships.find(
(r) =>
r.sourceTable === 'QuestRelation' &&
r.targetTable === 'QuestRelation' &&
r.sourceColumn === 'itsparentrel'
);
expect(selfRefFK).toBeDefined();
expect(selfRefFK?.targetColumn).toBe('BOID');
});
it('should parse multiple tables with circular dependencies', async () => {
const sql = `
CREATE TABLE [DBO].[Dragon](
[DRAGONID] (VARCHAR)(32),
[NAME] [VARCHAR](100),
[ITSLAIRREL] [VARCHAR](32), FOREIGN KEY (itslairrel) REFERENCES DragonLair(LAIRID),
[POWER] INT,
) ON [PRIMARY]
CREATE TABLE [DBO].[DragonLair](
[LAIRID] (VARCHAR)(32),
[LOCATION] [VARCHAR](200),
[ITSGUARDIAN] [VARCHAR](32), FOREIGN KEY (itsguardian) REFERENCES Dragon(DRAGONID),
[TREASURES] INT,
) ON [PRIMARY]
`;
const result = await fromSQLServer(sql);
// Should create both tables despite circular dependency
expect(result.tables).toHaveLength(2);
const dragon = result.tables.find((t) => t.name === 'Dragon');
expect(dragon).toBeDefined();
const dragonLair = result.tables.find((t) => t.name === 'DragonLair');
expect(dragonLair).toBeDefined();
// Check foreign key relationships (may have one or both depending on parser behavior with circular deps)
expect(result.relationships.length).toBeGreaterThanOrEqual(1);
});
it('should handle exact user input format', async () => {
// Exact copy of the user's input with fantasy theme
const sql = `CREATE TABLE [DBO].[WizardDef](
[BOID] (VARCHAR)(32),
[HASEXTRACNTCOL] BOOLEAN,
[HISTORYCD] [VARCHAR](128),
[HISTORYCDFIX] BOOLEAN,
[ITSADWIZARDREL] [VARCHAR](32), FOREIGN KEY (itsadwizardrel) REFERENCES WizardRel(BOID),
[SHOWDETAILS] BOOLEAN, ) ON [PRIMARY]
CREATE TABLE [DBO].[WizardRel](
[ALIAS] CHAR (50),
[BOID] (VARCHAR)(32),
[ISOPTIONAL] BOOLEAN,
[ITSARWIZARDREL] [VARCHAR](32), FOREIGN KEY (itsarwizardrel) REFERENCES WizardRel(BOID),
[ITSARMETABO] [VARCHAR](32), FOREIGN KEY (itsarmetabo) REFERENCES MetaBO(BOID),
[KEYATTR] CHAR (100), ) ON [PRIMARY]`;
const result = await fromSQLServer(sql);
// This should create TWO tables, not just one
expect(result.tables).toHaveLength(2);
const wizardDef = result.tables.find((t) => t.name === 'WizardDef');
expect(wizardDef).toBeDefined();
expect(wizardDef?.columns).toHaveLength(6);
const wizardRel = result.tables.find((t) => t.name === 'WizardRel');
expect(wizardRel).toBeDefined();
expect(wizardRel?.columns).toHaveLength(6);
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import { fromSQLServer } from '../sqlserver';
describe('SQL Server FK Verification', () => {
it('should correctly parse FKs from complex fantasy SQL', async () => {
const sql = `CREATE TABLE [DBO].[SpellDefinition](
[SPELLID] (VARCHAR)(32),
[HASVERBALCOMP] BOOLEAN,
[INCANTATION] [VARCHAR](128),
[INCANTATIONFIX] BOOLEAN,
[ITSCOMPONENTREL] [VARCHAR](32), FOREIGN KEY (itscomponentrel) REFERENCES SpellComponent(SPELLID),
[SHOWVISUALS] BOOLEAN, ) ON [PRIMARY]
CREATE TABLE [DBO].[SpellComponent](
[ALIAS] CHAR (50),
[SPELLID] (VARCHAR)(32),
[ISOPTIONAL] BOOLEAN,
[ITSPARENTCOMP] [VARCHAR](32), FOREIGN KEY (itsparentcomp) REFERENCES SpellComponent(SPELLID),
[ITSSCHOOLMETA] [VARCHAR](32), FOREIGN KEY (itsschoolmeta) REFERENCES MagicSchool(SCHOOLID),
[KEYATTR] CHAR (100), ) ON [PRIMARY]`;
const result = await fromSQLServer(sql);
// Verify tables
expect(result.tables).toHaveLength(2);
expect(result.tables.map((t) => t.name).sort()).toEqual([
'SpellComponent',
'SpellDefinition',
]);
// Verify that FKs were found (even if MagicSchool doesn't exist)
// The parsing should find 3 FKs initially, but linkRelationships will filter out the one to MagicSchool
expect(result.relationships.length).toBeGreaterThanOrEqual(2);
// Verify specific FKs that should exist
const fk1 = result.relationships.find(
(r) =>
r.sourceTable === 'SpellDefinition' &&
r.sourceColumn.toLowerCase() === 'itscomponentrel' &&
r.targetTable === 'SpellComponent'
);
expect(fk1).toBeDefined();
expect(fk1?.targetColumn).toBe('SPELLID');
expect(fk1?.sourceTableId).toBeTruthy();
expect(fk1?.targetTableId).toBeTruthy();
const fk2 = result.relationships.find(
(r) =>
r.sourceTable === 'SpellComponent' &&
r.sourceColumn.toLowerCase() === 'itsparentcomp' &&
r.targetTable === 'SpellComponent'
);
expect(fk2).toBeDefined();
expect(fk2?.targetColumn).toBe('SPELLID');
expect(fk2?.sourceTableId).toBeTruthy();
expect(fk2?.targetTableId).toBeTruthy();
// Log for debugging
console.log('\n=== FK Verification Results ===');
console.log(
'Tables:',
result.tables.map((t) => `${t.schema}.${t.name}`)
);
console.log('Total FKs found:', result.relationships.length);
result.relationships.forEach((r, i) => {
console.log(
`FK ${i + 1}: ${r.sourceTable}.${r.sourceColumn} -> ${r.targetTable}.${r.targetColumn}`
);
console.log(` IDs: ${r.sourceTableId} -> ${r.targetTableId}`);
});
});
it('should parse inline FOREIGN KEY syntax correctly', async () => {
// Simplified test with just one FK to ensure parsing works
const sql = `CREATE TABLE [DBO].[WizardTower](
[TOWERID] INT,
[MASTERKEY] [VARCHAR](32), FOREIGN KEY (MASTERKEY) REFERENCES ArcaneGuild(GUILDID),
[NAME] VARCHAR(100)
) ON [PRIMARY]
CREATE TABLE [DBO].[ArcaneGuild](
[GUILDID] [VARCHAR](32),
[GUILDNAME] VARCHAR(100)
) ON [PRIMARY]`;
const result = await fromSQLServer(sql);
expect(result.tables).toHaveLength(2);
expect(result.relationships).toHaveLength(1);
expect(result.relationships[0].sourceColumn).toBe('MASTERKEY');
expect(result.relationships[0].targetColumn).toBe('GUILDID');
});
});

View File

@@ -342,6 +342,35 @@ function parseCreateTableManually(
// Process each part (column or constraint) // Process each part (column or constraint)
for (const part of parts) { for (const part of parts) {
// Handle standalone FOREIGN KEY definitions (without CONSTRAINT keyword)
// Format: FOREIGN KEY (column) REFERENCES Table(column)
if (part.match(/^\s*FOREIGN\s+KEY/i)) {
const fkMatch = part.match(
/FOREIGN\s+KEY\s*\(([^)]+)\)\s+REFERENCES\s+(?:\[?(\w+)\]?\.)??\[?(\w+)\]?\s*\(([^)]+)\)/i
);
if (fkMatch) {
const [
,
sourceCol,
targetSchema = 'dbo',
targetTable,
targetCol,
] = fkMatch;
relationships.push({
name: `FK_${tableName}_${sourceCol.trim().replace(/\[|\]/g, '')}`,
sourceTable: tableName,
sourceSchema: schema,
sourceColumn: sourceCol.trim().replace(/\[|\]/g, ''),
targetTable: targetTable || targetSchema,
targetSchema: targetTable ? targetSchema : 'dbo',
targetColumn: targetCol.trim().replace(/\[|\]/g, ''),
sourceTableId: tableId,
targetTableId: '', // Will be filled later
});
}
continue;
}
// Handle constraint definitions // Handle constraint definitions
if (part.match(/^\s*CONSTRAINT/i)) { if (part.match(/^\s*CONSTRAINT/i)) {
// Parse constraints // Parse constraints
@@ -435,6 +464,13 @@ function parseCreateTableManually(
columnMatch = part.match(/^\s*(\w+)\s+(\w+)\s+([\d,\s]+)\s+(.*)$/i); columnMatch = part.match(/^\s*(\w+)\s+(\w+)\s+([\d,\s]+)\s+(.*)$/i);
} }
// Handle unusual format: [COLUMN_NAME] (VARCHAR)(32)
if (!columnMatch) {
columnMatch = part.match(
/^\s*\[?(\w+)\]?\s+\((\w+)\)\s*\(([\d,\s]+|max)\)(.*)$/i
);
}
if (columnMatch) { if (columnMatch) {
const [, colName, baseType, typeArgs, rest] = columnMatch; const [, colName, baseType, typeArgs, rest] = columnMatch;
@@ -446,7 +482,37 @@ function parseCreateTableManually(
const inlineFkMatch = rest.match( const inlineFkMatch = rest.match(
/FOREIGN\s+KEY\s+REFERENCES\s+(?:\[?(\w+)\]?\.)??\[?(\w+)\]?\s*\(([^)]+)\)/i /FOREIGN\s+KEY\s+REFERENCES\s+(?:\[?(\w+)\]?\.)??\[?(\w+)\]?\s*\(([^)]+)\)/i
); );
if (inlineFkMatch) {
// Also check if there's a FOREIGN KEY after a comma with column name
// Format: , FOREIGN KEY (columnname) REFERENCES Table(column)
if (!inlineFkMatch && rest.includes('FOREIGN KEY')) {
const fkWithColumnMatch = rest.match(
/,\s*FOREIGN\s+KEY\s*\((\w+)\)\s+REFERENCES\s+(?:\[?(\w+)\]?\.)??\[?(\w+)\]?\s*\(([^)]+)\)/i
);
if (fkWithColumnMatch) {
const [, srcCol, targetSchema, targetTable, targetCol] =
fkWithColumnMatch;
// Only process if srcCol matches current colName (case-insensitive)
if (srcCol.toLowerCase() === colName.toLowerCase()) {
// Create FK relationship
relationships.push({
name: `FK_${tableName}_${colName}`,
sourceTable: tableName,
sourceSchema: schema,
sourceColumn: colName,
targetTable: targetTable || targetSchema,
targetSchema: targetTable
? targetSchema || 'dbo'
: 'dbo',
targetColumn: targetCol
.trim()
.replace(/\[|\]/g, ''),
sourceTableId: tableId,
targetTableId: '', // Will be filled later
});
}
}
} else if (inlineFkMatch) {
const [, targetSchema = 'dbo', targetTable, targetCol] = const [, targetSchema = 'dbo', targetTable, targetCol] =
inlineFkMatch; inlineFkMatch;
relationships.push({ relationships.push({
@@ -536,10 +602,36 @@ export async function fromSQLServer(
try { try {
// First, handle ALTER TABLE statements for foreign keys // First, handle ALTER TABLE statements for foreign keys
// Split by GO or semicolon for SQL Server // Split by GO or semicolon for SQL Server
const statements = sqlContent let statements = sqlContent
.split(/(?:GO\s*$|;\s*$)/im) .split(/(?:GO\s*$|;\s*$)/im)
.filter((stmt) => stmt.trim().length > 0); .filter((stmt) => stmt.trim().length > 0);
// Additional splitting for CREATE TABLE statements that might not be separated by semicolons
// If we have a statement with multiple CREATE TABLE, split them
const expandedStatements: string[] = [];
for (const stmt of statements) {
// Check if this statement contains multiple CREATE TABLE statements
if ((stmt.match(/CREATE\s+TABLE/gi) || []).length > 1) {
// Split by ") ON [PRIMARY]" followed by CREATE TABLE
const parts = stmt.split(
/\)\s*ON\s*\[PRIMARY\]\s*(?=CREATE\s+TABLE)/gi
);
for (let i = 0; i < parts.length; i++) {
let part = parts[i].trim();
// Re-add ") ON [PRIMARY]" to all parts except the last (which should already have it)
if (i < parts.length - 1 && part.length > 0) {
part += ') ON [PRIMARY]';
}
if (part.trim().length > 0) {
expandedStatements.push(part);
}
}
} else {
expandedStatements.push(stmt);
}
}
statements = expandedStatements;
const alterTableStatements = statements.filter( const alterTableStatements = statements.filter(
(stmt) => (stmt) =>
stmt.trim().toUpperCase().includes('ALTER TABLE') && stmt.trim().toUpperCase().includes('ALTER TABLE') &&

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
Table "bruit"."100-AAB-CABAS-Mdap" {
"qgs_fid" int [pk, not null]
"geom" geometry
"from" decimal(8,2)
"to" decimal(8,2)
"period" nvarchar(500)
"objectid" float
"insee" float
"nom" nvarchar(500)
"code_posta" int
"ut" nvarchar(500)
"territoire" nvarchar(500)
"surface" float
"perimetre" float
"ccodter" float
"numcom" nvarchar(500)
}

View File

@@ -0,0 +1 @@
{"id":"mqqwkkodtkfm","name":"NTP_CANPT-db","createdAt":"2025-08-27T17:03:48.994Z","updatedAt":"2025-08-27T17:12:54.617Z","databaseType":"sql_server","tables":[{"id":"e4qecug35j4b7q75u1j3sdca5","name":"100-AAB-CABAS-Mdap","schema":"bruit","x":100,"y":100,"fields":[{"id":"04liixxb8yenudc6gqjjbgm1r","name":"qgs_fid","type":{"id":"int","name":"int"},"primaryKey":true,"unique":true,"nullable":false,"createdAt":1739267036546},{"id":"hr29n1e1jgybuac3gcerk7jyi","name":"geom","type":{"id":"geometry","name":"geometry"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"jcqh683op52ovfwqwe0w0i2or","name":"from","type":{"id":"decimal","name":"decimal"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"precision":8,"scale":2},{"id":"xev33ok0oqqom2n1tabpp5eds","name":"to","type":{"id":"decimal","name":"decimal"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"precision":8,"scale":2},{"id":"pj36qhdpl0vice9tsyiaaef4l","name":"period","type":{"id":"nvarchar","name":"nvarchar"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"collation":"French_CI_AS"},{"id":"l4ce4a68j9h7l46p8dg5qi09u","name":"objectid","type":{"id":"float","name":"float"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"fi4s2aahfjdeelfkgnrk4q5mk","name":"insee","type":{"id":"float","name":"float"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"ujsajf0t5xg0td614lpxk32py","name":"nom","type":{"id":"nvarchar","name":"nvarchar"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"collation":"French_CI_AS"},{"id":"9j0c54ez2t5dgr0ybzd0ksbuz","name":"code_posta","type":{"id":"int","name":"int"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"gybxvu42odvvjyfoe9zdn7tul","name":"ut","type":{"id":"nvarchar","name":"nvarchar"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"collation":"French_CI_AS"},{"id":"qon7xs001v9q8frad6jr9lrho","name":"territoire","type":{"id":"nvarchar","name":"nvarchar"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"collation":"French_CI_AS"},{"id":"aeqrfvw5dvig7t8zyjfiri707","name":"surface","type":{"id":"float","name":"float"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"eqbcy7gfd49a3a6ds6ne6fmzd","name":"perimetre","type":{"id":"float","name":"float"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"cbxmodo9l3keqxapqnlfjnqy2","name":"ccodter","type":{"id":"float","name":"float"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"c3j131aycof5kgyiypva428l3","name":"numcom","type":{"id":"nvarchar","name":"nvarchar"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"collation":"French_CI_AS"}],"indexes":[],"color":"#8a61f5","isView":false,"isMaterializedView":false,"createdAt":1739267036546,"diagramId":"mqqwkkodtkfm","expanded":true}],"relationships":[],"dependencies":[],"areas":[],"customTypes":[]}

View File

@@ -0,0 +1,8 @@
Table "public"."guy_table" {
"id" integer [pk, not null]
"created_at" timestamp [not null]
"column3" text
"arrayfield" text[]
"field_5" "character varying"
"field_6" "character varying(100)"
}

View File

@@ -0,0 +1 @@
{"id":"mqqwkkod7trl","name":"guy-db","databaseType":"postgresql","createdAt":"2025-09-10T18:45:32.817Z","updatedAt":"2025-09-10T19:15:21.682Z","tables":[{"id":"g2hv9mlo3qbyjnxdc44j1zxl2","name":"guy_table","schema":"public","x":100,"y":300,"fields":[{"id":"qdqgzmtxsi84ujfuktsvjuop8","name":"id","type":{"id":"integer","name":"integer"},"primaryKey":true,"unique":true,"nullable":false,"createdAt":1757529932816},{"id":"wsys99f86679ch6fbjryw0egr","name":"created_at","type":{"id":"timestamp_without_time_zone","name":"timestamp without time zone"},"primaryKey":false,"unique":false,"nullable":false,"createdAt":1757529932816},{"id":"ro39cba7sd290k90qjgzib8pi","name":"column3","type":{"id":"text","name":"text"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1757529932816},{"id":"6cntbu2orwk7kxlg0rcduqgbo","name":"arrayfield","type":{"id":"array","name":"array"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1757529932816},{"id":"7cz0ybdoov2m3wbgm9tlzatz0","name":"field_5","type":{"id":"character_varying","name":"character varying"},"unique":false,"nullable":true,"primaryKey":false,"createdAt":1757531685981},{"id":"zzwlyvqzz93oh0vv8f8qob103","name":"field_6","type":{"id":"character_varying","name":"character varying"},"unique":false,"nullable":true,"primaryKey":false,"createdAt":1757531713961,"characterMaximumLength":"100"}],"indexes":[{"id":"r0w71lnbnje2j9cz1t9j64rya","name":"guy_table_pkey","unique":true,"fieldIds":["qdqgzmtxsi84ujfuktsvjuop8"],"createdAt":1757529932816,"isPrimaryKey":true}],"color":"#8eb7ff","isView":false,"isMaterializedView":false,"createdAt":1757529932816,"diagramId":"mqqwkkod7trl"}],"relationships":[],"dependencies":[],"areas":[],"customTypes":[]}

View File

@@ -0,0 +1,438 @@
import { describe, it, expect } from 'vitest';
import { generateDBMLFromDiagram } from '../dbml-export';
import { DatabaseType } from '@/lib/domain/database-type';
import type { Diagram } from '@/lib/domain/diagram';
import type { DBTable } from '@/lib/domain/db-table';
import type { DBField } from '@/lib/domain/db-field';
describe('DBML Export - Fix Multiline Table Names', () => {
// Helper to generate test IDs and timestamps
let idCounter = 0;
const testId = () => `test-id-${++idCounter}`;
const testTime = Date.now();
// Helper to create a field
const createField = (overrides: Partial<DBField>): DBField =>
({
id: testId(),
name: 'field',
type: { id: 'text', name: 'text' },
primaryKey: false,
nullable: true,
unique: false,
createdAt: testTime,
...overrides,
}) as DBField;
// Helper to create a table
const createTable = (overrides: Partial<DBTable>): DBTable =>
({
id: testId(),
name: 'table',
fields: [],
indexes: [],
createdAt: testTime,
x: 0,
y: 0,
width: 200,
...overrides,
}) as DBTable;
// Helper to create a diagram
const createDiagram = (overrides: Partial<Diagram>): Diagram =>
({
id: testId(),
name: 'diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [],
relationships: [],
createdAt: testTime,
updatedAt: testTime,
...overrides,
}) as Diagram;
describe('DBML Generation with Special Characters', () => {
it('should handle table names with special characters', () => {
const diagram = createDiagram({
tables: [
createTable({
name: 'user-profiles',
fields: [
createField({
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
}),
createField({
name: 'user-name',
type: { id: 'varchar', name: 'varchar' },
nullable: true,
}),
],
}),
],
});
const result = generateDBMLFromDiagram(diagram);
// Should properly quote table names with special characters
expect(result.standardDbml).toContain('Table "user-profiles"');
// Field names with special characters should also be quoted
expect(result.standardDbml).toContain('"user-name"');
// Should not have any errors
expect(result.error).toBeUndefined();
});
it('should handle schema-qualified table names', () => {
const diagram = createDiagram({
tables: [
createTable({
schema: 'my-schema',
name: 'my-table',
fields: [
createField({
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
}),
],
}),
],
});
const result = generateDBMLFromDiagram(diagram);
// Should properly quote schema and table names
expect(result.standardDbml).toContain(
'Table "my-schema"."my-table"'
);
// Should not have any errors
expect(result.error).toBeUndefined();
});
it('should handle table names with spaces', () => {
const diagram = createDiagram({
tables: [
createTable({
name: 'user profiles',
fields: [
createField({
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
}),
],
}),
],
});
const result = generateDBMLFromDiagram(diagram);
// Should properly quote table names with spaces
expect(result.standardDbml).toContain('Table "user profiles"');
// Should not have any errors
expect(result.error).toBeUndefined();
});
it('should handle schema names with spaces', () => {
const diagram = createDiagram({
tables: [
createTable({
schema: 'my schema',
name: 'my_table',
fields: [
createField({
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
}),
],
}),
],
});
const result = generateDBMLFromDiagram(diagram);
// Should properly quote schema with spaces
expect(result.standardDbml).toContain(
'Table "my schema"."my_table"'
);
// Should not have any errors
expect(result.error).toBeUndefined();
});
it('should handle table names with dots', () => {
const diagram = createDiagram({
tables: [
createTable({
name: 'app.config',
fields: [
createField({
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
}),
],
}),
],
});
const result = generateDBMLFromDiagram(diagram);
// Should properly quote table names with dots
expect(result.standardDbml).toContain('Table "app.config"');
// Should not have any errors
expect(result.error).toBeUndefined();
});
it('should not have line breaks in table declarations', () => {
const diagram = createDiagram({
tables: [
createTable({
schema: 'very-long-schema-name-with-dashes',
name: 'very-long-table-name-with-special-characters',
fields: [
createField({
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
}),
],
}),
],
});
const result = generateDBMLFromDiagram(diagram);
// Table declaration should be on a single line
const tableDeclarations =
result.standardDbml.match(/Table\s+[^{]+\{/g) || [];
tableDeclarations.forEach((decl) => {
// Should not contain newlines before the opening brace
expect(decl).not.toContain('\n');
});
// The full qualified name should be on one line
expect(result.standardDbml).toMatch(
/Table\s+"very-long-schema-name-with-dashes"\."very-long-table-name-with-special-characters"\s*\{/
);
});
});
describe('Multiple tables and relationships', () => {
it('should handle multiple tables with special characters', () => {
const parentTableId = testId();
const childTableId = testId();
const parentIdField = testId();
const childParentIdField = testId();
const diagram = createDiagram({
tables: [
createTable({
id: parentTableId,
schema: 'auth-schema',
name: 'user-accounts',
fields: [
createField({
id: parentIdField,
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
}),
],
}),
createTable({
id: childTableId,
schema: 'app-schema',
name: 'user-profiles',
fields: [
createField({
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
}),
createField({
id: childParentIdField,
name: 'account-id',
type: { id: 'uuid', name: 'uuid' },
nullable: false,
}),
],
}),
],
relationships: [
{
id: testId(),
name: 'fk_profiles_accounts',
sourceTableId: childTableId,
targetTableId: parentTableId,
sourceFieldId: childParentIdField,
targetFieldId: parentIdField,
sourceCardinality: 'many',
targetCardinality: 'one',
createdAt: testTime,
},
],
});
const result = generateDBMLFromDiagram(diagram);
// Should contain both tables properly quoted
expect(result.standardDbml).toContain(
'Table "auth-schema"."user-accounts"'
);
expect(result.standardDbml).toContain(
'Table "app-schema"."user-profiles"'
);
// Should contain the relationship reference
expect(result.standardDbml).toContain('Ref');
// Should contain field names with dashes properly quoted
expect(result.standardDbml).toContain('"account-id"');
// Should not have any errors
expect(result.error).toBeUndefined();
});
it('should work correctly with inline DBML format', () => {
const parentTableId = testId();
const childTableId = testId();
const parentIdField = testId();
const childParentIdField = testId();
const diagram = createDiagram({
tables: [
createTable({
id: parentTableId,
name: 'parent-table',
fields: [
createField({
id: parentIdField,
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
}),
],
}),
createTable({
id: childTableId,
name: 'child-table',
fields: [
createField({
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
}),
createField({
id: childParentIdField,
name: 'parent-id',
type: { id: 'integer', name: 'integer' },
nullable: false,
}),
],
}),
],
relationships: [
{
id: testId(),
name: 'fk_child_parent',
sourceTableId: childTableId,
targetTableId: parentTableId,
sourceFieldId: childParentIdField,
targetFieldId: parentIdField,
sourceCardinality: 'many',
targetCardinality: 'one',
createdAt: testTime,
},
],
});
const result = generateDBMLFromDiagram(diagram);
// Both standard and inline should be generated
expect(result.standardDbml).toBeDefined();
expect(result.inlineDbml).toBeDefined();
// Inline version should contain inline references
expect(result.inlineDbml).toContain('ref:');
// Both should properly quote table names
expect(result.standardDbml).toContain('Table "parent-table"');
expect(result.inlineDbml).toContain('Table "parent-table"');
expect(result.standardDbml).toContain('Table "child-table"');
expect(result.inlineDbml).toContain('Table "child-table"');
// Should not have any errors
expect(result.error).toBeUndefined();
});
});
describe('Edge cases', () => {
it('should handle empty table names gracefully', () => {
const diagram = createDiagram({
tables: [
createTable({
name: '',
fields: [
createField({
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
}),
],
}),
],
});
const result = generateDBMLFromDiagram(diagram);
// Should not throw error
expect(result.error).toBeUndefined();
});
it('should handle Unicode characters in names', () => {
const diagram = createDiagram({
tables: [
createTable({
name: 'użytkownik',
fields: [
createField({
name: 'identyfikator',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
}),
],
}),
],
});
const result = generateDBMLFromDiagram(diagram);
// Should handle Unicode characters
expect(result.standardDbml).toContain('Table "użytkownik"');
expect(result.standardDbml).toContain('"identyfikator"');
// Should not have any errors
expect(result.error).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { diagramFromJSONInput } from '@/lib/export-import-utils';
import { generateDBMLFromDiagram } from '../dbml-export';
import * as fs from 'fs';
import * as path from 'path';
describe('DBML Export - Diagram Case 1 Tests', () => {
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);
});
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);
});
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');
// 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', '3.dbml');
const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8');
// Compare the generated DBML with the expected DBML
expect(generatedDBML).toBe(expectedDBML);
});
});

View File

@@ -1,5 +1,5 @@
import { importer } from '@dbml/core'; import { importer } from '@dbml/core';
import { exportBaseSQL } from '@/lib/data/export-metadata/export-sql-script'; import { exportBaseSQL } from '@/lib/data/sql-export/export-sql-script';
import type { Diagram } from '@/lib/domain/diagram'; import type { Diagram } from '@/lib/domain/diagram';
import { DatabaseType } from '@/lib/domain/database-type'; import { DatabaseType } from '@/lib/domain/database-type';
import type { DBTable } from '@/lib/domain/db-table'; import type { DBTable } from '@/lib/domain/db-table';
@@ -596,6 +596,13 @@ const normalizeCharTypeFormat = (dbml: string): string => {
.replace(/character \(([0-9]+)\)/g, 'character($1)'); .replace(/character \(([0-9]+)\)/g, 'character($1)');
}; };
// Fix array types that are incorrectly quoted by DBML importer
const fixArrayTypes = (dbml: string): string => {
// Remove quotes around array types like "text[]" -> text[]
// Matches patterns like: "fieldname" "type[]" and replaces with "fieldname" type[]
return dbml.replace(/(\s+"[^"]+"\s+)"([^"\s]+\[\])"/g, '$1$2');
};
// Fix table definitions with incorrect bracket syntax // Fix table definitions with incorrect bracket syntax
const fixTableBracketSyntax = (dbml: string): string => { const fixTableBracketSyntax = (dbml: string): string => {
// Fix patterns like Table [schema].[table] to Table "schema"."table" // Fix patterns like Table [schema].[table] to Table "schema"."table"
@@ -605,6 +612,23 @@ const fixTableBracketSyntax = (dbml: string): string => {
); );
}; };
// Fix table names that have been broken across multiple lines
const fixMultilineTableNames = (dbml: string): string => {
// Match Table declarations that might have line breaks in the table name
// This regex captures:
// - Table keyword
// - Optional quoted schema with dot
// - Table name that might be broken across lines (until the opening brace)
return dbml.replace(
/Table\s+((?:"[^"]*"\.)?"[^"]*(?:\n[^"]*)*")\s*\{/g,
(_, tableName) => {
// Remove line breaks within the table name
const fixedTableName = tableName.replace(/\n\s*/g, '');
return `Table ${fixedTableName} {`;
}
);
};
// Restore composite primary key names in the DBML // Restore composite primary key names in the DBML
const restoreCompositePKNames = (dbml: string, tables: DBTable[]): string => { const restoreCompositePKNames = (dbml: string, tables: DBTable[]): string => {
if (!tables || tables.length === 0) return dbml; if (!tables || tables.length === 0) return dbml;
@@ -968,11 +992,15 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
); );
} }
standard = normalizeCharTypeFormat( standard = fixArrayTypes(
fixTableBracketSyntax( normalizeCharTypeFormat(
importer.import( fixMultilineTableNames(
baseScript, fixTableBracketSyntax(
databaseTypeToImportFormat(diagram.databaseType) importer.import(
baseScript,
databaseTypeToImportFormat(diagram.databaseType)
)
)
) )
) )
); );
@@ -988,7 +1016,9 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
standard = enumsDBML + '\n\n' + standard; standard = enumsDBML + '\n\n' + standard;
} }
inline = normalizeCharTypeFormat(convertToInlineRefs(standard)); inline = fixArrayTypes(
normalizeCharTypeFormat(convertToInlineRefs(standard))
);
// Clean up excessive empty lines in both outputs // Clean up excessive empty lines in both outputs
standard = standard.replace(/\n\s*\n\s*\n/g, '\n\n'); standard = standard.replace(/\n\s*\n\s*\n/g, '\n\n');

View File

@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { importDBMLToDiagram } from '../dbml-import'; import { importDBMLToDiagram } from '../dbml-import';
import { exportPostgreSQL } from '@/lib/data/export-metadata/export-per-type/postgresql'; import { exportPostgreSQL } from '@/lib/data/sql-export/export-per-type/postgresql';
import { exportMySQL } from '@/lib/data/export-metadata/export-per-type/mysql'; import { exportMySQL } from '@/lib/data/sql-export/export-per-type/mysql';
import { exportMSSQL } from '@/lib/data/export-metadata/export-per-type/mssql'; import { exportMSSQL } from '@/lib/data/sql-export/export-per-type/mssql';
import { DatabaseType } from '@/lib/domain/database-type'; import { DatabaseType } from '@/lib/domain/database-type';
describe('Composite Primary Key with Name', () => { describe('Composite Primary Key with Name', () => {

View File

@@ -154,7 +154,7 @@ Note note_1750185617764 {
// Should not throw // Should not throw
const parser = new Parser(); const parser = new Parser();
expect(() => parser.parse(sanitized, 'dbml')).not.toThrow(); expect(() => parser.parse(sanitized, 'dbmlv2')).not.toThrow();
}); });
}); });

View File

@@ -1,3 +1,5 @@
import type { CompilerError } from '@dbml/core/types/parse/error';
export interface DBMLError { export interface DBMLError {
message: string; message: string;
line: number; line: number;
@@ -9,28 +11,12 @@ export function parseDBMLError(error: unknown): DBMLError | null {
if (typeof error === 'string') { if (typeof error === 'string') {
const parsed = JSON.parse(error); const parsed = JSON.parse(error);
if (parsed.diags?.[0]) { if (parsed.diags?.[0]) {
const diag = parsed.diags[0]; const parsedError = parsed as CompilerError;
return getFirstErrorFromCompileError(parsedError);
return {
message: diag.message,
line: diag.location.start.line,
column: diag.location.start.column,
};
} }
} else if (error && typeof error === 'object' && 'diags' in error) { } else if (error && typeof error === 'object' && 'diags' in error) {
const parsed = error as { const parsed = error as CompilerError;
diags: Array<{ return getFirstErrorFromCompileError(parsed);
message: string;
location: { start: { line: number; column: number } };
}>;
};
if (parsed.diags?.[0]) {
return {
message: parsed.diags[0].message,
line: parsed.diags[0].location.start.line,
column: parsed.diags[0].location.start.column,
};
}
} }
} catch (e) { } catch (e) {
console.error('Error parsing DBML error:', e); console.error('Error parsing DBML error:', e);
@@ -38,3 +24,25 @@ export function parseDBMLError(error: unknown): DBMLError | null {
return null; return null;
} }
const getFirstErrorFromCompileError = (
error: CompilerError
): DBMLError | null => {
const diags = (error.diags ?? []).sort((a, b) => {
if (a.location.start.line === b.location.start.line) {
return a.location.start.column - b.location.start.column;
}
return a.location.start.line - b.location.start.line;
});
if (diags.length > 0) {
const firstDiag = diags[0];
return {
message: firstDiag.message,
line: firstDiag.location.start.line,
column: firstDiag.location.start.column,
};
}
return null;
};

View File

@@ -223,7 +223,7 @@ export const importDBMLToDiagram = async (
}; };
} }
const parsedData = parser.parse(sanitizedContent, 'dbml'); const parsedData = parser.parse(sanitizedContent, 'dbmlv2');
// Handle case where no schemas are found // Handle case where no schemas are found
if (!parsedData.schemas || parsedData.schemas.length === 0) { if (!parsedData.schemas || parsedData.schemas.length === 0) {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createTablesFromMetadata } from '../db-table'; import { createTablesFromMetadata } from '@/lib/data/import-metadata/import/tables';
import { DatabaseType } from '../database-type'; import { DatabaseType } from '../database-type';
import type { DatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata'; import type { DatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata';

View File

@@ -1,7 +1,4 @@
import { z } from 'zod'; import { z } from 'zod';
import type { DBCustomTypeInfo } from '@/lib/data/import-metadata/metadata-types/custom-type-info';
import { generateId } from '../utils';
import { schemaNameToDomainSchemaName } from './db-schema';
export enum DBCustomTypeKind { export enum DBCustomTypeKind {
enum = 'enum', enum = 'enum',
@@ -15,12 +12,12 @@ export interface DBCustomTypeField {
export interface DBCustomType { export interface DBCustomType {
id: string; id: string;
schema?: string; schema?: string | null;
name: string; name: string;
kind: DBCustomTypeKind; kind: DBCustomTypeKind;
values?: string[]; // For enum types values?: string[] | null; // For enum types
fields?: DBCustomTypeField[]; // For composite types fields?: DBCustomTypeField[] | null; // For composite types
order?: number; order?: number | null;
} }
export const dbCustomTypeFieldSchema = z.object({ export const dbCustomTypeFieldSchema = z.object({
@@ -30,30 +27,14 @@ export const dbCustomTypeFieldSchema = z.object({
export const dbCustomTypeSchema: z.ZodType<DBCustomType> = z.object({ export const dbCustomTypeSchema: z.ZodType<DBCustomType> = z.object({
id: z.string(), id: z.string(),
schema: z.string(), schema: z.string().or(z.null()).optional(),
name: z.string(), name: z.string(),
kind: z.nativeEnum(DBCustomTypeKind), kind: z.nativeEnum(DBCustomTypeKind),
values: z.array(z.string()).optional(), values: z.array(z.string()).or(z.null()).optional(),
fields: z.array(dbCustomTypeFieldSchema).optional(), fields: z.array(dbCustomTypeFieldSchema).or(z.null()).optional(),
order: z.number().or(z.null()).optional(),
}); });
export const createCustomTypesFromMetadata = ({
customTypes,
}: {
customTypes: DBCustomTypeInfo[];
}): DBCustomType[] => {
return customTypes.map((customType) => {
return {
id: generateId(),
schema: schemaNameToDomainSchemaName(customType.schema),
name: customType.type,
kind: customType.kind as DBCustomTypeKind,
values: customType.values,
fields: customType.fields,
};
});
};
export const customTypeKindToLabel: Record<DBCustomTypeKind, string> = { export const customTypeKindToLabel: Record<DBCustomTypeKind, string> = {
enum: 'Enum', enum: 'Enum',
composite: 'Composite', composite: 'Composite',

View File

@@ -1,10 +1,4 @@
import { z } from 'zod'; import { z } from 'zod';
import type { ViewInfo } from '../data/import-metadata/metadata-types/view-info';
import { DatabaseType } from './database-type';
import { schemaNameToDomainSchemaName } from './db-schema';
import { decodeViewDefinition, type DBTable } from './db-table';
import { generateId } from '@/lib/utils';
import type { AST } from 'node-sql-parser';
export interface DBDependency { export interface DBDependency {
id: string; id: string;
@@ -23,348 +17,3 @@ export const dbDependencySchema: z.ZodType<DBDependency> = z.object({
dependentTableId: z.string(), dependentTableId: z.string(),
createdAt: z.number(), createdAt: z.number(),
}); });
const astDatabaseTypes: Record<DatabaseType, string> = {
[DatabaseType.POSTGRESQL]: 'postgresql',
[DatabaseType.MYSQL]: 'postgresql',
[DatabaseType.MARIADB]: 'postgresql',
[DatabaseType.GENERIC]: 'postgresql',
[DatabaseType.SQLITE]: 'postgresql',
[DatabaseType.SQL_SERVER]: 'postgresql',
[DatabaseType.CLICKHOUSE]: 'postgresql',
[DatabaseType.COCKROACHDB]: 'postgresql',
[DatabaseType.ORACLE]: 'postgresql',
};
export const createDependenciesFromMetadata = async ({
views,
tables,
databaseType,
}: {
views: ViewInfo[];
tables: DBTable[];
databaseType: DatabaseType;
}): Promise<DBDependency[]> => {
if (!views || views.length === 0) {
return [];
}
const { Parser } = await import('node-sql-parser');
const parser = new Parser();
const dependencies = views
.flatMap((view) => {
const viewSchema = schemaNameToDomainSchemaName(view.schema);
const viewTable = tables.find(
(table) =>
table.name === view.view_name && viewSchema === table.schema
);
if (!viewTable) {
console.warn(
`Source table for view ${view.view_name} not found (schema: ${viewSchema})`
);
return []; // Skip this view and proceed to the next
}
if (view.view_definition) {
try {
const decodedViewDefinition = decodeViewDefinition(
databaseType,
view.view_definition
);
let modifiedViewDefinition = '';
if (
databaseType === DatabaseType.MYSQL ||
databaseType === DatabaseType.MARIADB
) {
modifiedViewDefinition = preprocessViewDefinitionMySQL(
decodedViewDefinition
);
} else if (databaseType === DatabaseType.SQL_SERVER) {
modifiedViewDefinition =
preprocessViewDefinitionSQLServer(
decodedViewDefinition
);
} else {
modifiedViewDefinition = preprocessViewDefinition(
decodedViewDefinition
);
}
// Parse using the appropriate dialect
const ast = parser.astify(modifiedViewDefinition, {
database: astDatabaseTypes[databaseType],
type: 'select', // Parsing a SELECT statement
});
let relatedTables = extractTablesFromAST(ast);
// Filter out duplicate tables without schema
relatedTables = filterDuplicateTables(relatedTables);
return relatedTables.map((relTable) => {
const relSchema = relTable.schema || view.schema; // Use view's schema if relSchema is undefined
const relTableName = relTable.tableName;
const table = tables.find(
(table) =>
table.name === relTableName &&
(table.schema || '') === relSchema
);
if (table) {
const dependency: DBDependency = {
id: generateId(),
schema: view.schema,
tableId: table.id, // related table
dependentSchema: table.schema,
dependentTableId: viewTable.id, // dependent view
createdAt: Date.now(),
};
return dependency;
} else {
console.warn(
`Dependent table ${relSchema}.${relTableName} not found for view ${view.schema}.${view.view_name}`
);
return null;
}
});
} catch (error) {
console.error(
`Error parsing view ${view.schema}.${view.view_name}:`,
error
);
return [];
}
} else {
console.warn(
`View definition missing for ${view.schema}.${view.view_name}`
);
return [];
}
})
.filter((dependency) => dependency !== null);
return dependencies;
};
// Add this new function to filter out duplicate tables
function filterDuplicateTables(
tables: { schema?: string; tableName: string }[]
): { schema?: string; tableName: string }[] {
const tableMap = new Map<string, { schema?: string; tableName: string }>();
for (const table of tables) {
const key = table.tableName;
const existingTable = tableMap.get(key);
if (!existingTable || (table.schema && !existingTable.schema)) {
tableMap.set(key, table);
}
}
return Array.from(tableMap.values());
}
// Preprocess the view_definition to remove schema from CREATE VIEW
function preprocessViewDefinition(viewDefinition: string): string {
if (!viewDefinition) {
return '';
}
// Remove leading and trailing whitespace
viewDefinition = viewDefinition.replace(/\s+/g, ' ').trim();
// Replace escaped double quotes with regular ones
viewDefinition = viewDefinition.replace(/\\"/g, '"');
// Replace 'CREATE MATERIALIZED VIEW' with 'CREATE VIEW'
viewDefinition = viewDefinition.replace(
/CREATE\s+MATERIALIZED\s+VIEW/i,
'CREATE VIEW'
);
// Regular expression to match 'CREATE VIEW [schema.]view_name [ (column definitions) ] AS'
// This regex captures the view name and skips any content between the view name and 'AS'
const regex =
/CREATE\s+VIEW\s+(?:(?:`[^`]+`|"[^"]+"|\w+)\.)?(?:`([^`]+)`|"([^"]+)"|(\w+))[\s\S]*?\bAS\b\s+/i;
const match = viewDefinition.match(regex);
let modifiedDefinition: string;
if (match) {
const viewName = match[1] || match[2] || match[3];
// Extract the SQL after the 'AS' keyword
const restOfDefinition = viewDefinition.substring(
match.index! + match[0].length
);
// Replace double-quoted identifiers with unquoted ones
let modifiedSQL = restOfDefinition.replace(/"(\w+)"/g, '$1');
// Replace '::' type casts with 'CAST' expressions
modifiedSQL = modifiedSQL.replace(
/\(([^()]+)\)::(\w+)/g,
'CAST($1 AS $2)'
);
// Remove ClickHouse-specific syntax that may still be present
// For example, remove SETTINGS clauses inside the SELECT statement
modifiedSQL = modifiedSQL.replace(/\bSETTINGS\b[\s\S]*$/i, '');
modifiedDefinition = `CREATE VIEW ${viewName} AS ${modifiedSQL}`;
} else {
console.warn('Could not preprocess view definition:', viewDefinition);
modifiedDefinition = viewDefinition;
}
return modifiedDefinition;
}
// Preprocess the view_definition for SQL Server
function preprocessViewDefinitionSQLServer(viewDefinition: string): string {
if (!viewDefinition) {
return '';
}
// Remove BOM if present
viewDefinition = viewDefinition.replace(/^\uFEFF/, '');
// Normalize whitespace
viewDefinition = viewDefinition.replace(/\s+/g, ' ').trim();
// Remove square brackets and replace with double quotes
viewDefinition = viewDefinition.replace(/\[([^\]]+)\]/g, '"$1"');
// Remove database names from fully qualified identifiers
viewDefinition = viewDefinition.replace(
/"([a-zA-Z0-9_]+)"\."([a-zA-Z0-9_]+)"\."([a-zA-Z0-9_]+)"/g,
'"$2"."$3"'
);
// Replace SQL Server functions with PostgreSQL equivalents
viewDefinition = viewDefinition.replace(/\bGETDATE\(\)/gi, 'NOW()');
viewDefinition = viewDefinition.replace(/\bISNULL\(/gi, 'COALESCE(');
// Replace 'TOP N' with 'LIMIT N' at the end of the query
const topMatch = viewDefinition.match(/SELECT\s+TOP\s+(\d+)/i);
if (topMatch) {
const topN = topMatch[1];
viewDefinition = viewDefinition.replace(
/SELECT\s+TOP\s+\d+/i,
'SELECT'
);
viewDefinition = viewDefinition.replace(/;+\s*$/, ''); // Remove semicolons at the end
viewDefinition += ` LIMIT ${topN}`;
}
viewDefinition = viewDefinition.replace(/\n/g, ''); // Remove newlines
// Adjust CREATE VIEW syntax
const regex =
/CREATE\s+VIEW\s+(?:"?([^".\s]+)"?\.)?"?([^".\s]+)"?\s+AS\s+/i;
const match = viewDefinition.match(regex);
let modifiedDefinition: string;
if (match) {
const viewName = match[2];
const modifiedSQL = viewDefinition.substring(
match.index! + match[0].length
);
// Remove semicolons at the end
const finalSQL = modifiedSQL.replace(/;+\s*$/, '');
modifiedDefinition = `CREATE VIEW "${viewName}" AS ${finalSQL}`;
} else {
console.warn('Could not preprocess view definition:', viewDefinition);
modifiedDefinition = viewDefinition;
}
return modifiedDefinition;
}
// Preprocess the view_definition to remove schema from CREATE VIEW
function preprocessViewDefinitionMySQL(viewDefinition: string): string {
if (!viewDefinition) {
return '';
}
// Remove any trailing semicolons
viewDefinition = viewDefinition.replace(/;\s*$/, '');
// Remove backticks from identifiers
viewDefinition = viewDefinition.replace(/`/g, '');
// Remove unnecessary parentheses around joins and ON clauses
viewDefinition = removeRedundantParentheses(viewDefinition);
return viewDefinition;
}
function removeRedundantParentheses(sql: string): string {
// Regular expressions to match unnecessary parentheses
const patterns = [
/\(\s*(JOIN\s+[^()]+?)\s*\)/gi,
/\(\s*(ON\s+[^()]+?)\s*\)/gi,
// Additional patterns if necessary
];
let prevSql;
do {
prevSql = sql;
patterns.forEach((pattern) => {
sql = sql.replace(pattern, '$1');
});
} while (sql !== prevSql);
return sql;
}
function extractTablesFromAST(
ast: AST | AST[]
): { schema?: string; tableName: string }[] {
const tablesMap = new Map<string, { schema: string; tableName: string }>();
const visitedNodes = new Set();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function traverse(node: any) {
if (!node || visitedNodes.has(node)) return;
visitedNodes.add(node);
if (Array.isArray(node)) {
node.forEach(traverse);
} else if (typeof node === 'object') {
// Check if node represents a table
if (
Object.hasOwnProperty.call(node, 'table') &&
typeof node.table === 'string'
) {
let schema = node.db || node.schema;
const tableName = node.table;
if (tableName) {
// Assign default schema if undefined
schema = schemaNameToDomainSchemaName(schema) || '';
const key = `${schema}.${tableName}`;
if (!tablesMap.has(key)) {
tablesMap.set(key, { schema, tableName });
}
}
}
// Recursively traverse all properties
for (const key in node) {
if (Object.hasOwnProperty.call(node, key)) {
traverse(node[key]);
}
}
}
}
traverse(ast);
return Array.from(tablesMap.values());
}

View File

@@ -4,11 +4,6 @@ import {
findDataTypeDataById, findDataTypeDataById,
type DataType, type DataType,
} from '../data/data-types/data-types'; } from '../data/data-types/data-types';
import type { ColumnInfo } from '../data/import-metadata/metadata-types/column-info';
import type { AggregatedIndexInfo } from '../data/import-metadata/metadata-types/index-info';
import type { PrimaryKeyInfo } from '../data/import-metadata/metadata-types/primary-key-info';
import type { TableInfo } from '../data/import-metadata/metadata-types/table-info';
import { generateId } from '../utils';
import type { DatabaseType } from './database-type'; import type { DatabaseType } from './database-type';
export interface DBField { export interface DBField {
@@ -45,64 +40,6 @@ export const dbFieldSchema: z.ZodType<DBField> = z.object({
comments: z.string().or(z.null()).optional(), comments: z.string().or(z.null()).optional(),
}); });
export const createFieldsFromMetadata = ({
tableColumns,
tablePrimaryKeys,
aggregatedIndexes,
}: {
tableColumns: ColumnInfo[];
tableSchema?: string;
tableInfo: TableInfo;
tablePrimaryKeys: PrimaryKeyInfo[];
aggregatedIndexes: AggregatedIndexInfo[];
}) => {
const uniqueColumns = tableColumns.reduce((acc, col) => {
if (!acc.has(col.name)) {
acc.set(col.name, col);
}
return acc;
}, new Map<string, ColumnInfo>());
const sortedColumns = Array.from(uniqueColumns.values()).sort(
(a, b) => a.ordinal_position - b.ordinal_position
);
const tablePrimaryKeysColumns = tablePrimaryKeys.map((pk) =>
pk.column.trim()
);
return sortedColumns.map(
(col: ColumnInfo): DBField => ({
id: generateId(),
name: col.name,
type: {
id: col.type.split(' ').join('_').toLowerCase(),
name: col.type.toLowerCase(),
},
primaryKey: tablePrimaryKeysColumns.includes(col.name),
unique: Object.values(aggregatedIndexes).some(
(idx) =>
idx.unique &&
idx.columns.length === 1 &&
idx.columns[0].name === col.name
),
nullable: Boolean(col.nullable),
...(col.character_maximum_length &&
col.character_maximum_length !== 'null'
? { characterMaximumLength: col.character_maximum_length }
: {}),
...(col.precision?.precision
? { precision: col.precision.precision }
: {}),
...(col.precision?.scale ? { scale: col.precision.scale } : {}),
...(col.default ? { default: col.default } : {}),
...(col.collation ? { collation: col.collation } : {}),
createdAt: Date.now(),
comments: col.comment ? col.comment : undefined,
})
);
};
export const generateDBFieldSuffix = ( export const generateDBFieldSuffix = (
field: DBField, field: DBField,
{ {

View File

@@ -1,7 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import type { AggregatedIndexInfo } from '../data/import-metadata/metadata-types/index-info';
import { generateId } from '../utils'; import { generateId } from '../utils';
import type { DBField } from './db-field';
import { DatabaseType } from './database-type'; import { DatabaseType } from './database-type';
import type { DBTable } from './db-table'; import type { DBTable } from './db-table';
@@ -43,27 +41,6 @@ export const dbIndexSchema: z.ZodType<DBIndex> = z.object({
isPrimaryKey: z.boolean().or(z.null()).optional(), isPrimaryKey: z.boolean().or(z.null()).optional(),
}); });
export const createIndexesFromMetadata = ({
aggregatedIndexes,
fields,
}: {
aggregatedIndexes: AggregatedIndexInfo[];
fields: DBField[];
}): DBIndex[] =>
aggregatedIndexes.map(
(idx): DBIndex => ({
id: generateId(),
name: idx.name,
unique: Boolean(idx.unique),
fieldIds: idx.columns
.sort((a, b) => a.position - b.position)
.map((c) => fields.find((f) => f.name === c.name)?.id)
.filter((id): id is string => id !== undefined),
createdAt: Date.now(),
type: idx.index_type?.toLowerCase() as IndexType,
})
);
export const databaseIndexTypes: { [key in DatabaseType]?: IndexType[] } = { export const databaseIndexTypes: { [key in DatabaseType]?: IndexType[] } = {
[DatabaseType.POSTGRESQL]: ['btree', 'hash'], [DatabaseType.POSTGRESQL]: ['btree', 'hash'],
}; };

View File

@@ -1,9 +1,4 @@
import { z } from 'zod'; import { z } from 'zod';
import type { ForeignKeyInfo } from '../data/import-metadata/metadata-types/foreign-key-info';
import type { DBField } from './db-field';
import { schemaNameToDomainSchemaName } from './db-schema';
import type { DBTable } from './db-table';
import { generateId } from '@/lib/utils';
export interface DBRelationship { export interface DBRelationship {
id: string; id: string;
@@ -40,82 +35,6 @@ export type RelationshipType =
| 'many_to_many'; | 'many_to_many';
export type Cardinality = 'one' | 'many'; export type Cardinality = 'one' | 'many';
const determineCardinality = (
field: DBField,
isTablePKComplex: boolean
): Cardinality => {
return field.unique || (field.primaryKey && !isTablePKComplex)
? 'one'
: 'many';
};
export const createRelationshipsFromMetadata = ({
foreignKeys,
tables,
}: {
foreignKeys: ForeignKeyInfo[];
tables: DBTable[];
}): DBRelationship[] => {
return foreignKeys
.map((fk: ForeignKeyInfo): DBRelationship | null => {
const schema = schemaNameToDomainSchemaName(fk.schema);
const sourceTable = tables.find(
(table) => table.name === fk.table && table.schema === schema
);
const targetSchema = schemaNameToDomainSchemaName(
fk.reference_schema
);
const targetTable = tables.find(
(table) =>
table.name === fk.reference_table &&
table.schema === targetSchema
);
const sourceField = sourceTable?.fields.find(
(field) => field.name === fk.column
);
const targetField = targetTable?.fields.find(
(field) => field.name === fk.reference_column
);
const isSourceTablePKComplex =
(sourceTable?.fields.filter((field) => field.primaryKey) ?? [])
.length > 1;
const isTargetTablePKComplex =
(targetTable?.fields.filter((field) => field.primaryKey) ?? [])
.length > 1;
if (sourceTable && targetTable && sourceField && targetField) {
const sourceCardinality = determineCardinality(
sourceField,
isSourceTablePKComplex
);
const targetCardinality = determineCardinality(
targetField,
isTargetTablePKComplex
);
return {
id: generateId(),
name: fk.foreign_key_name,
sourceSchema: schema,
targetSchema: targetSchema,
sourceTableId: sourceTable.id,
targetTableId: targetTable.id,
sourceFieldId: sourceField.id,
targetFieldId: targetField.id,
sourceCardinality,
targetCardinality,
createdAt: Date.now(),
};
}
return null;
})
.filter((rel) => rel !== null) as DBRelationship[];
};
export const determineRelationshipType = ({ export const determineRelationshipType = ({
sourceCardinality, sourceCardinality,
targetCardinality, targetCardinality,

View File

@@ -1,30 +1,8 @@
import { import { dbIndexSchema, type DBIndex } from './db-index';
createIndexesFromMetadata, import { dbFieldSchema, type DBField } from './db-field';
dbIndexSchema,
type DBIndex,
} from './db-index';
import {
createFieldsFromMetadata,
dbFieldSchema,
type DBField,
} from './db-field';
import type { TableInfo } from '../data/import-metadata/metadata-types/table-info';
import { createAggregatedIndexes } from '../data/import-metadata/metadata-types/index-info';
import {
materializedViewColor,
viewColor,
defaultTableColor,
} from '@/lib/colors';
import type { DBRelationship } from './db-relationship'; import type { DBRelationship } from './db-relationship';
import { import { deepCopy } from '../utils';
decodeBase64ToUtf16LE,
decodeBase64ToUtf8,
deepCopy,
generateId,
} from '../utils';
import { schemaNameToDomainSchemaName } from './db-schema'; import { schemaNameToDomainSchemaName } from './db-schema';
import { DatabaseType } from './database-type';
import type { DatabaseMetadata } from '../data/import-metadata/metadata-types/database-metadata';
import { z } from 'zod'; import { z } from 'zod';
import type { Area } from './area'; import type { Area } from './area';
@@ -79,213 +57,6 @@ export const generateTableKey = ({
tableName: string; tableName: string;
}) => `${schemaNameToDomainSchemaName(schemaName) ?? ''}.${tableName}`; }) => `${schemaNameToDomainSchemaName(schemaName) ?? ''}.${tableName}`;
export const decodeViewDefinition = (
databaseType: DatabaseType,
viewDefinition?: string
): string => {
if (!viewDefinition) {
return '';
}
let decodedViewDefinition: string;
if (databaseType === DatabaseType.SQL_SERVER) {
decodedViewDefinition = decodeBase64ToUtf16LE(viewDefinition);
} else {
decodedViewDefinition = decodeBase64ToUtf8(viewDefinition);
}
return decodedViewDefinition;
};
export const createTablesFromMetadata = ({
databaseMetadata,
databaseType,
}: {
databaseMetadata: DatabaseMetadata;
databaseType: DatabaseType;
}): DBTable[] => {
const {
tables: tableInfos,
pk_info: primaryKeys,
columns,
indexes,
views: views,
} = databaseMetadata;
// Pre-compute view names for faster lookup if there are views
const viewNamesSet = new Set<string>();
const materializedViewNamesSet = new Set<string>();
if (views && views.length > 0) {
views.forEach((view) => {
const key = generateTableKey({
schemaName: view.schema,
tableName: view.view_name,
});
viewNamesSet.add(key);
if (
view.view_definition &&
decodeViewDefinition(databaseType, view.view_definition)
.toLowerCase()
.includes('materialized')
) {
materializedViewNamesSet.add(key);
}
});
}
// Pre-compute lookup maps for better performance
const columnsByTable = new Map<string, (typeof columns)[0][]>();
const indexesByTable = new Map<string, (typeof indexes)[0][]>();
const primaryKeysByTable = new Map<string, (typeof primaryKeys)[0][]>();
// Group columns by table
columns.forEach((col) => {
const key = generateTableKey({
schemaName: col.schema,
tableName: col.table,
});
if (!columnsByTable.has(key)) {
columnsByTable.set(key, []);
}
columnsByTable.get(key)!.push(col);
});
// Group indexes by table
indexes.forEach((idx) => {
const key = generateTableKey({
schemaName: idx.schema,
tableName: idx.table,
});
if (!indexesByTable.has(key)) {
indexesByTable.set(key, []);
}
indexesByTable.get(key)!.push(idx);
});
// Group primary keys by table
primaryKeys.forEach((pk) => {
const key = generateTableKey({
schemaName: pk.schema,
tableName: pk.table,
});
if (!primaryKeysByTable.has(key)) {
primaryKeysByTable.set(key, []);
}
primaryKeysByTable.get(key)!.push(pk);
});
const result = tableInfos.map((tableInfo: TableInfo) => {
const tableSchema = schemaNameToDomainSchemaName(tableInfo.schema);
const tableKey = generateTableKey({
schemaName: tableInfo.schema,
tableName: tableInfo.table,
});
// Use pre-computed lookups instead of filtering entire arrays
const tableIndexes = indexesByTable.get(tableKey) || [];
const tablePrimaryKeys = primaryKeysByTable.get(tableKey) || [];
const tableColumns = columnsByTable.get(tableKey) || [];
// Aggregate indexes with multiple columns
const aggregatedIndexes = createAggregatedIndexes({
tableInfo,
tableSchema,
tableIndexes,
});
const fields = createFieldsFromMetadata({
aggregatedIndexes,
tableColumns,
tablePrimaryKeys,
tableInfo,
tableSchema,
});
// Check for composite primary key and find matching index name
const primaryKeyFields = fields.filter((f) => f.primaryKey);
let pkMatchingIndexName: string | undefined;
let pkIndex: DBIndex | undefined;
if (primaryKeyFields.length >= 1) {
// We have a composite primary key, look for an index that matches all PK columns
const pkFieldNames = primaryKeyFields.map((f) => f.name).sort();
// Find an index that matches the primary key columns exactly
const matchingIndex = aggregatedIndexes.find((index) => {
const indexColumnNames = index.columns
.map((c) => c.name)
.sort();
return (
indexColumnNames.length === pkFieldNames.length &&
indexColumnNames.every((col, i) => col === pkFieldNames[i])
);
});
if (matchingIndex) {
pkMatchingIndexName = matchingIndex.name;
// Create a special PK index
pkIndex = {
id: generateId(),
name: matchingIndex.name,
unique: true,
fieldIds: primaryKeyFields.map((f) => f.id),
createdAt: Date.now(),
isPrimaryKey: true,
};
}
}
// Filter out the index that matches the composite PK (to avoid duplication)
const filteredAggregatedIndexes = pkMatchingIndexName
? aggregatedIndexes.filter(
(idx) => idx.name !== pkMatchingIndexName
)
: aggregatedIndexes;
const dbIndexes = createIndexesFromMetadata({
aggregatedIndexes: filteredAggregatedIndexes,
fields,
});
// Add the PK index if it exists
if (pkIndex) {
dbIndexes.push(pkIndex);
}
// Determine if the current table is a view by checking against pre-computed sets
const viewKey = generateTableKey({
schemaName: tableSchema,
tableName: tableInfo.table,
});
const isView = viewNamesSet.has(viewKey);
const isMaterializedView = materializedViewNamesSet.has(viewKey);
// Initial random positions; these will be adjusted later
return {
id: generateId(),
name: tableInfo.table,
schema: tableSchema,
x: Math.random() * 1000, // Placeholder X
y: Math.random() * 800, // Placeholder Y
fields,
indexes: dbIndexes,
color: isMaterializedView
? materializedViewColor
: isView
? viewColor
: defaultTableColor,
isView: isView,
isMaterializedView: isMaterializedView,
createdAt: Date.now(),
comments: tableInfo.comment ? tableInfo.comment : undefined,
};
});
return result;
};
export const adjustTablePositions = ({ export const adjustTablePositions = ({
relationships: inputRelationships, relationships: inputRelationships,
tables: inputTables, tables: inputTables,

View File

@@ -1,30 +1,15 @@
import { z } from 'zod'; import { z } from 'zod';
import type { DatabaseMetadata } from '../data/import-metadata/metadata-types/database-metadata';
import { DatabaseEdition } from './database-edition'; import { DatabaseEdition } from './database-edition';
import { DatabaseType } from './database-type'; import { DatabaseType } from './database-type';
import type { DBDependency } from './db-dependency'; import type { DBDependency } from './db-dependency';
import { import { dbDependencySchema } from './db-dependency';
createDependenciesFromMetadata,
dbDependencySchema,
} from './db-dependency';
import type { DBRelationship } from './db-relationship'; import type { DBRelationship } from './db-relationship';
import { import { dbRelationshipSchema } from './db-relationship';
createRelationshipsFromMetadata,
dbRelationshipSchema,
} from './db-relationship';
import type { DBTable } from './db-table'; import type { DBTable } from './db-table';
import { import { dbTableSchema } from './db-table';
adjustTablePositions,
createTablesFromMetadata,
dbTableSchema,
} from './db-table';
import { generateDiagramId } from '@/lib/utils';
import { areaSchema, type Area } from './area'; import { areaSchema, type Area } from './area';
import type { DBCustomType } from './db-custom-type'; import type { DBCustomType } from './db-custom-type';
import { import { dbCustomTypeSchema } from './db-custom-type';
dbCustomTypeSchema,
createCustomTypesFromMetadata,
} from './db-custom-type';
export interface Diagram { export interface Diagram {
id: string; id: string;
@@ -53,77 +38,3 @@ export const diagramSchema: z.ZodType<Diagram> = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
}); });
export const loadFromDatabaseMetadata = async ({
databaseType,
databaseMetadata,
diagramNumber,
databaseEdition,
}: {
databaseType: DatabaseType;
databaseMetadata: DatabaseMetadata;
diagramNumber?: number;
databaseEdition?: DatabaseEdition;
}): Promise<Diagram> => {
const {
fk_info: foreignKeys,
views: views,
custom_types: customTypes,
} = databaseMetadata;
const tables = createTablesFromMetadata({
databaseMetadata,
databaseType,
});
const relationships = createRelationshipsFromMetadata({
foreignKeys,
tables,
});
const dependencies = await createDependenciesFromMetadata({
views,
tables,
databaseType,
});
const dbCustomTypes = customTypes
? createCustomTypesFromMetadata({
customTypes,
})
: [];
const adjustedTables = adjustTablePositions({
tables,
relationships,
mode: 'perSchema',
});
const sortedTables = adjustedTables.sort((a, b) => {
if (a.isView === b.isView) {
// Both are either tables or views, so sort alphabetically by name
return a.name.localeCompare(b.name);
}
// If one is a view and the other is not, put tables first
return a.isView ? 1 : -1;
});
const diagram: Diagram = {
id: generateDiagramId(),
name: databaseMetadata.database_name
? `${databaseMetadata.database_name}-db`
: diagramNumber
? `Diagram ${diagramNumber}`
: 'New Diagram',
databaseType: databaseType ?? DatabaseType.GENERIC,
databaseEdition,
tables: sortedTables,
relationships,
dependencies,
customTypes: dbCustomTypes,
createdAt: new Date(),
updatedAt: new Date(),
};
return diagram;
};

View File

@@ -57,3 +57,40 @@ export type DiffObject<
| FieldDiff<TField>['object'] | FieldDiff<TField>['object']
| IndexDiff<TIndex>['object'] | IndexDiff<TIndex>['object']
| RelationshipDiff<TRelationship>['object']; | RelationshipDiff<TRelationship>['object'];
type ExtractDiffKind<T> = T extends { object: infer O; type: infer Type }
? T extends { attribute: infer A }
? { object: O; type: Type; attribute: A }
: { object: O; type: Type }
: never;
export type DiffKind<
TTable = DBTable,
TField = DBField,
TIndex = DBIndex,
TRelationship = DBRelationship,
> = ExtractDiffKind<ChartDBDiff<TTable, TField, TIndex, TRelationship>>;
export const isDiffOfKind = <
TTable = DBTable,
TField = DBField,
TIndex = DBIndex,
TRelationship = DBRelationship,
>(
diff: ChartDBDiff<TTable, TField, TIndex, TRelationship>,
kind: DiffKind<TTable, TField, TIndex, TRelationship>
): boolean => {
if ('attribute' in kind) {
return (
diff.object === kind.object &&
diff.type === kind.type &&
diff.attribute === kind.attribute
);
}
if ('attribute' in diff) {
return false;
}
return diff.object === kind.object && diff.type === kind.type;
};

View File

@@ -77,13 +77,15 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
} }
}} }}
> >
<NodeResizer {!readonly ? (
isVisible={focused} <NodeResizer
lineClassName="!border-4 !border-transparent" isVisible={focused}
handleClassName="!h-[10px] !w-[10px] !rounded-full !bg-pink-600" lineClassName="!border-4 !border-transparent"
minHeight={100} handleClassName="!h-[10px] !w-[10px] !rounded-full !bg-pink-600"
minWidth={100} minHeight={100}
/> minWidth={100}
/>
) : null}
<div className="group flex h-8 items-center justify-between rounded-t-md px-2"> <div className="group flex h-8 items-center justify-between rounded-t-md px-2">
<div className="flex w-full items-center gap-1"> <div className="flex w-full items-center gap-1">
<GripVertical className="size-4 shrink-0 text-slate-700 opacity-60 dark:text-slate-300" /> <GripVertical className="size-4 shrink-0 text-slate-700 opacity-60 dark:text-slate-300" />

View File

@@ -28,6 +28,7 @@ import { FilterItemActions } from './filter-item-actions';
import { databasesWithSchemas } from '@/lib/domain'; import { databasesWithSchemas } from '@/lib/domain';
import { getOperatingSystem } from '@/lib/utils'; import { getOperatingSystem } from '@/lib/utils';
import { useLocalConfig } from '@/hooks/use-local-config'; import { useLocalConfig } from '@/hooks/use-local-config';
import { useDiff } from '@/context/diff-context/use-diff';
export interface CanvasFilterProps { export interface CanvasFilterProps {
onClose: () => void; onClose: () => void;
@@ -36,6 +37,7 @@ export interface CanvasFilterProps {
export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => { export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { tables, databaseType, areas } = useChartDB(); const { tables, databaseType, areas } = useChartDB();
const { checkIfNewTable } = useDiff();
const { const {
filter, filter,
toggleSchemaFilter, toggleSchemaFilter,
@@ -45,7 +47,7 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
addTablesToFilter, addTablesToFilter,
removeTablesFromFilter, removeTablesFromFilter,
} = useDiagramFilter(); } = useDiagramFilter();
const { fitView, setNodes } = useReactFlow(); const { setNodes } = useReactFlow();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [isFilterVisible, setIsFilterVisible] = useState(false); const [isFilterVisible, setIsFilterVisible] = useState(false);
@@ -58,13 +60,14 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
() => () =>
tables tables
.filter((table) => (showDBViews ? true : !table.isView)) .filter((table) => (showDBViews ? true : !table.isView))
.filter((table) => !checkIfNewTable({ tableId: table.id }))
.map((table) => ({ .map((table) => ({
id: table.id, id: table.id,
name: table.name, name: table.name,
schema: table.schema, schema: table.schema,
parentAreaId: table.parentAreaId, parentAreaId: table.parentAreaId,
})), })),
[tables, showDBViews] [tables, showDBViews, checkIfNewTable]
); );
const databaseWithSchemas = useMemo( const databaseWithSchemas = useMemo(
@@ -157,39 +160,53 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
] ]
); );
const focusOnTable = useCallback( const selectTable = useCallback(
(tableId: string) => { (tableId: string) => {
// Make sure the table is visible // Make sure the table is visible, selected and trigger animation
setNodes((nodes) => setNodes((nodes) =>
nodes.map((node) => nodes.map((node) => {
node.id === tableId if (node.id === tableId) {
? { return {
...node, ...node,
hidden: false, selected: true,
selected: true, data: {
} ...node.data,
: { highlightTable: true,
...node, },
selected: false, };
} }
)
return {
...node,
selected: false,
data: {
...node.data,
highlightTable: false,
},
};
})
); );
// Focus on the table // Remove the highlight flag after animation completes
setTimeout(() => { setTimeout(() => {
fitView({ setNodes((nodes) =>
duration: 500, nodes.map((node) => {
maxZoom: 1, if (node.id === tableId) {
minZoom: 1, return {
nodes: [ ...node,
{ data: {
id: tableId, ...node.data,
}, highlightTable: false,
], },
}); };
}, 100); }
return node;
})
);
}, 600);
}, },
[fitView, setNodes] [setNodes]
); );
// Handle node click // Handle node click
@@ -199,13 +216,13 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
const context = node.context as TableContext; const context = node.context as TableContext;
const isTableVisible = context.visible; const isTableVisible = context.visible;
// Only focus if table is visible // Only select if table is visible
if (isTableVisible) { if (isTableVisible) {
focusOnTable(node.id); selectTable(node.id);
} }
} }
}, },
[focusOnTable] [selectTable]
); );
// Animate in on mount and focus search input // Animate in on mount and focus search input

View File

@@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import { Eye, EyeOff } from 'lucide-react'; import { Eye, EyeOff, CircleDotDashed } from 'lucide-react';
import { Button } from '@/components/button/button'; import { Button } from '@/components/button/button';
import type { TreeNode } from '@/components/tree-view/tree'; import type { TreeNode } from '@/components/tree-view/tree';
import { schemaNameToSchemaId } from '@/lib/domain/db-schema'; import { schemaNameToSchemaId } from '@/lib/domain/db-schema';
import { useFocusOn } from '@/hooks/use-focus-on';
import type { import type {
AreaContext, AreaContext,
NodeContext, NodeContext,
@@ -12,6 +13,7 @@ import type {
TableContext, TableContext,
} from './types'; } from './types';
import type { FilterTableInfo } from '@/lib/domain/diagram-filter/diagram-filter'; import type { FilterTableInfo } from '@/lib/domain/diagram-filter/diagram-filter';
import { cn } from '@/lib/utils';
interface FilterItemActionsProps { interface FilterItemActionsProps {
node: TreeNode<NodeType, NodeContext>; node: TreeNode<NodeType, NodeContext>;
@@ -40,6 +42,7 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
addTablesToFilter, addTablesToFilter,
removeTablesFromFilter, removeTablesFromFilter,
}) => { }) => {
const { focusOnArea, focusOnTable } = useFocusOn();
if (node.type === 'schema') { if (node.type === 'schema') {
const context = node.context as SchemaContext; const context = node.context as SchemaContext;
const schemaVisible = context.visible; const schemaVisible = context.visible;
@@ -50,7 +53,7 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="size-7 h-fit p-0" className="h-fit w-6 p-0"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -67,9 +70,9 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
}} }}
> >
{!schemaVisible ? ( {!schemaVisible ? (
<EyeOff className="size-3.5 text-muted-foreground" /> <EyeOff className="!size-3.5 text-muted-foreground" />
) : ( ) : (
<Eye className="size-3.5" /> <Eye className="!size-3.5" />
)} )}
</Button> </Button>
); );
@@ -81,37 +84,60 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
const isUngrouped = context.isUngrouped; const isUngrouped = context.isUngrouped;
const areaId = context.id; const areaId = context.id;
const handleZoomToArea = (e: React.MouseEvent) => {
e.stopPropagation();
if (!isUngrouped) {
focusOnArea(areaId);
}
};
return ( return (
<Button <div className="flex h-full items-center gap-0.5">
variant="ghost" <Button
size="sm" variant="ghost"
className="size-7 h-fit p-0" size="sm"
onClick={(e) => { className={cn(
e.stopPropagation(); 'flex h-fit w-6 items-center justify-center p-0 opacity-0 transition-opacity group-hover:opacity-100',
// Toggle all tables in this area {
if (areaVisible) { '!opacity-0': !areaVisible,
// Hide all tables in this area }
removeTablesFromFilter({ )}
filterCallback: (table) => onClick={handleZoomToArea}
(isUngrouped && !table.areaId) || disabled={!areaVisible}
(!isUngrouped && table.areaId === areaId), >
}); <CircleDotDashed className="!size-3.5" />
} else { </Button>
// Show all tables in this area <Button
addTablesToFilter({ variant="ghost"
filterCallback: (table) => size="sm"
(isUngrouped && !table.areaId) || className="flex h-fit w-6 items-center justify-center p-0"
(!isUngrouped && table.areaId === areaId), onClick={(e) => {
}); e.stopPropagation();
} // Toggle all tables in this area
}} if (areaVisible) {
> // Hide all tables in this area
{!areaVisible ? ( removeTablesFromFilter({
<EyeOff className="size-3.5 text-muted-foreground" /> filterCallback: (table) =>
) : ( (isUngrouped && !table.areaId) ||
<Eye className="size-3.5" /> (!isUngrouped && table.areaId === areaId),
)} });
</Button> } else {
// Show all tables in this area
addTablesToFilter({
filterCallback: (table) =>
(isUngrouped && !table.areaId) ||
(!isUngrouped && table.areaId === areaId),
});
}
}}
>
{!areaVisible ? (
<EyeOff className="!size-3.5 text-muted-foreground" />
) : (
<Eye className="!size-3.5" />
)}
</Button>
</div>
); );
} }
@@ -120,22 +146,43 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({
const context = node.context as TableContext; const context = node.context as TableContext;
const tableVisible = context.visible; const tableVisible = context.visible;
const handleZoomToTable = (e: React.MouseEvent) => {
e.stopPropagation();
focusOnTable(tableId);
};
return ( return (
<Button <div className="flex h-full items-center gap-0.5">
variant="ghost" <Button
size="sm" variant="ghost"
className="size-7 h-fit p-0" size="sm"
onClick={(e) => { className={cn(
e.stopPropagation(); 'flex h-fit w-6 items-center justify-center p-0 opacity-0 transition-opacity group-hover:opacity-100',
toggleTableFilter(tableId); {
}} '!opacity-0': !tableVisible,
> }
{!tableVisible ? ( )}
<EyeOff className="size-3.5 text-muted-foreground" /> onClick={handleZoomToTable}
) : ( disabled={!tableVisible}
<Eye className="size-3.5" /> >
)} <CircleDotDashed className="!size-3.5" />
</Button> </Button>
<Button
variant="ghost"
size="sm"
className="flex w-6 items-center justify-center p-0"
onClick={(e) => {
e.stopPropagation();
toggleTableFilter(tableId);
}}
>
{!tableVisible ? (
<EyeOff className="!size-3.5 text-muted-foreground" />
) : (
<Eye className="!size-3.5" />
)}
</Button>
</div>
); );
} }

View File

@@ -40,13 +40,7 @@ import {
} from './table-node/table-node-field'; } from './table-node/table-node-field';
import { Toolbar } from './toolbar/toolbar'; import { Toolbar } from './toolbar/toolbar';
import { useToast } from '@/components/toast/use-toast'; import { useToast } from '@/components/toast/use-toast';
import { import { Pencil, AlertTriangle, Magnet, Highlighter } from 'lucide-react';
Pencil,
LayoutGrid,
AlertTriangle,
Magnet,
Highlighter,
} from 'lucide-react';
import { Button } from '@/components/button/button'; import { Button } from '@/components/button/button';
import { useLayout } from '@/hooks/use-layout'; import { useLayout } from '@/hooks/use-layout';
import { useBreakpoint } from '@/hooks/use-breakpoint'; import { useBreakpoint } from '@/hooks/use-breakpoint';
@@ -81,7 +75,6 @@ import {
TOP_SOURCE_HANDLE_ID_PREFIX, TOP_SOURCE_HANDLE_ID_PREFIX,
} from './table-node/table-node-dependency-indicator'; } from './table-node/table-node-dependency-indicator';
import type { DatabaseType } from '@/lib/domain/database-type'; import type { DatabaseType } from '@/lib/domain/database-type';
import { useAlert } from '@/context/alert-context/alert-context';
import { useCanvas } from '@/hooks/use-canvas'; import { useCanvas } from '@/hooks/use-canvas';
import type { AreaNodeType } from './area-node/area-node'; import type { AreaNodeType } from './area-node/area-node';
import { AreaNode } from './area-node/area-node'; import { AreaNode } from './area-node/area-node';
@@ -95,6 +88,7 @@ import type { DiagramFilter } from '@/lib/domain/diagram-filter/diagram-filter';
import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter'; import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter';
import { filterTable } from '@/lib/domain/diagram-filter/filter'; import { filterTable } from '@/lib/domain/diagram-filter/filter';
import { defaultSchemas } from '@/lib/data/default-schemas'; import { defaultSchemas } from '@/lib/data/default-schemas';
import { useDiff } from '@/context/diff-context/use-diff';
const HIGHLIGHTED_EDGE_Z_INDEX = 1; const HIGHLIGHTED_EDGE_Z_INDEX = 1;
const DEFAULT_EDGE_Z_INDEX = 0; const DEFAULT_EDGE_Z_INDEX = 0;
@@ -124,16 +118,33 @@ const tableToTableNode = (
databaseType, databaseType,
filterLoading, filterLoading,
showDBViews, showDBViews,
forceShow,
}: { }: {
filter?: DiagramFilter; filter?: DiagramFilter;
databaseType: DatabaseType; databaseType: DatabaseType;
filterLoading: boolean; filterLoading: boolean;
showDBViews?: boolean; showDBViews?: boolean;
forceShow?: boolean;
} }
): TableNodeType => { ): TableNodeType => {
// Always use absolute position for now // Always use absolute position for now
const position = { x: table.x, y: table.y }; const position = { x: table.x, y: table.y };
let hidden = false;
if (forceShow) {
hidden = false;
} else {
hidden =
!filterTable({
table: { id: table.id, schema: table.schema },
filter,
options: { defaultSchema: defaultSchemas[databaseType] },
}) ||
filterLoading ||
(!showDBViews && table.isView);
}
return { return {
id: table.id, id: table.id,
type: 'table', type: 'table',
@@ -143,14 +154,7 @@ const tableToTableNode = (
isOverlapping: false, isOverlapping: false,
}, },
width: table.width ?? MIN_TABLE_SIZE, width: table.width ?? MIN_TABLE_SIZE,
hidden: hidden,
!filterTable({
table: { id: table.id, schema: table.schema },
filter,
options: { defaultSchema: defaultSchemas[databaseType] },
}) ||
filterLoading ||
(!showDBViews && table.isView),
}; };
}; };
@@ -231,12 +235,10 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
const { showSidePanel } = useLayout(); const { showSidePanel } = useLayout();
const { effectiveTheme } = useTheme(); const { effectiveTheme } = useTheme();
const { scrollAction, showDBViews, showMiniMapOnCanvas } = useLocalConfig(); const { scrollAction, showDBViews, showMiniMapOnCanvas } = useLocalConfig();
const { showAlert } = useAlert();
const { isMd: isDesktop } = useBreakpoint('md'); const { isMd: isDesktop } = useBreakpoint('md');
const [highlightOverlappingTables, setHighlightOverlappingTables] = const [highlightOverlappingTables, setHighlightOverlappingTables] =
useState(false); useState(false);
const { const {
reorderTables,
fitView, fitView,
setOverlapGraph, setOverlapGraph,
overlapGraph, overlapGraph,
@@ -244,6 +246,14 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
setShowFilter, setShowFilter,
} = useCanvas(); } = useCanvas();
const { filter, loading: filterLoading } = useDiagramFilter(); const { filter, loading: filterLoading } = useDiagramFilter();
const { checkIfNewTable } = useDiff();
const shouldForceShowTable = useCallback(
(tableId: string) => {
return checkIfNewTable({ tableId });
},
[checkIfNewTable]
);
const [isInitialLoadingNodes, setIsInitialLoadingNodes] = useState(true); const [isInitialLoadingNodes, setIsInitialLoadingNodes] = useState(true);
@@ -254,6 +264,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
databaseType, databaseType,
filterLoading, filterLoading,
showDBViews, showDBViews,
forceShow: shouldForceShowTable(table.id),
}) })
) )
); );
@@ -273,6 +284,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
databaseType, databaseType,
filterLoading, filterLoading,
showDBViews, showDBViews,
forceShow: shouldForceShowTable(table.id),
}) })
); );
if (equal(initialNodes, nodes)) { if (equal(initialNodes, nodes)) {
@@ -285,6 +297,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
databaseType, databaseType,
filterLoading, filterLoading,
showDBViews, showDBViews,
shouldForceShowTable,
]); ]);
useEffect(() => { useEffect(() => {
@@ -445,6 +458,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
databaseType, databaseType,
filterLoading, filterLoading,
showDBViews, showDBViews,
forceShow: shouldForceShowTable(table.id),
}); });
// Check if table uses the highlighted custom type // Check if table uses the highlighted custom type
@@ -495,6 +509,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
highlightedCustomType, highlightedCustomType,
filterLoading, filterLoading,
showDBViews, showDBViews,
shouldForceShowTable,
]); ]);
const prevFilter = useRef<DiagramFilter | undefined>(undefined); const prevFilter = useRef<DiagramFilter | undefined>(undefined);
@@ -1185,16 +1200,6 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
const isLoadingDOM = const isLoadingDOM =
tables.length > 0 ? !getInternalNode(tables[0].id) : false; tables.length > 0 ? !getInternalNode(tables[0].id) : false;
const showReorderConfirmation = useCallback(() => {
showAlert({
title: t('reorder_diagram_alert.title'),
description: t('reorder_diagram_alert.description'),
actionLabel: t('reorder_diagram_alert.reorder'),
closeLabel: t('reorder_diagram_alert.cancel'),
onAction: reorderTables,
});
}, [t, showAlert, reorderTables]);
const hasOverlappingTables = useMemo( const hasOverlappingTables = useMemo(
() => () =>
Array.from(overlapGraph.graph).some( Array.from(overlapGraph.graph).some(
@@ -1261,24 +1266,6 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
<div className="flex flex-col items-center gap-2 md:flex-row"> <div className="flex flex-col items-center gap-2 md:flex-row">
{!readonly ? ( {!readonly ? (
<> <>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
className="size-8 p-1 shadow-none"
onClick={
showReorderConfirmation
}
>
<LayoutGrid className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{t('toolbar.reorder_diagram')}
</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span> <span>

View File

@@ -0,0 +1,60 @@
/* Custom scrollbar styles for table edit mode */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
}
/* Webkit browsers (Chrome, Safari, Edge) */
.custom-scrollbar::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 6px;
margin: 4px 0;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 6px;
border: 2px solid transparent;
background-clip: padding-box;
min-height: 40px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.custom-scrollbar::-webkit-scrollbar-thumb:active {
background: #64748b;
}
/* Dark mode styles */
.dark .custom-scrollbar {
scrollbar-color: #475569 transparent;
}
.dark .custom-scrollbar::-webkit-scrollbar-track {
background: #1e293b;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: #475569;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb:active {
background: #94a3b8;
}
/* Always show scrollbar when content is scrollable */
.custom-scrollbar:hover::-webkit-scrollbar-thumb,
.custom-scrollbar::-webkit-scrollbar-thumb {
visibility: visible;
}

View File

@@ -0,0 +1,859 @@
import React, {
useState,
useCallback,
useEffect,
useRef,
useMemo,
memo,
} from 'react';
import { Button } from '@/components/button/button';
import { Input } from '@/components/input/input';
import { Plus, Trash2, X } from 'lucide-react';
import type { DBField } from '@/lib/domain/db-field';
import type { DBTable } from '@/lib/domain/db-table';
import { useChartDB } from '@/hooks/use-chartdb';
import { generateId } from '@/lib/utils';
import type { SelectBoxOption } from '@/components/select-box/select-box';
import { SelectBox } from '@/components/select-box/select-box';
import { generateDBFieldSuffix } from '@/lib/domain/db-field';
import { dataTypeDataToDataType } from '@/lib/data/data-types/data-types';
import type { DataTypeData } from '@/lib/data/data-types/data-types';
import { sortedDataTypeMap } from '@/lib/data/data-types/data-types';
import { DatabaseType } from '@/lib/domain/database-type';
import { Checkbox } from '@/components/checkbox/checkbox';
import { cn } from '@/lib/utils';
import './table-edit-mode.css';
interface TableEditModeProps {
table: DBTable;
color?: string;
focusFieldId?: string;
onClose: () => void;
}
interface FieldRowProps {
field: DBField;
dataTypeOptions: SelectBoxOption[];
databaseType: DatabaseType;
onTypeChange: (
fieldId: string,
value: string,
regexMatches?: string[]
) => void;
onNameChange: (
fieldId: string,
e: React.ChangeEvent<HTMLInputElement>
) => void;
onPrimaryKeyChange: (fieldId: string, checked: boolean) => void;
onRemove: (fieldId: string) => void;
inputRef?: (el: HTMLInputElement | null) => void;
}
const FieldRow = memo<FieldRowProps>(
({
field,
dataTypeOptions,
databaseType,
onTypeChange,
onNameChange,
onPrimaryKeyChange,
onRemove,
inputRef,
}) => {
return (
<div className="mb-2 grid grid-cols-[1fr,150px,60px,40px] items-center gap-3 rounded-md p-2 hover:bg-slate-50 dark:hover:bg-slate-800">
<Input
ref={inputRef}
value={field.name}
onChange={(e) => onNameChange(field.id, e)}
className="h-9 text-sm font-medium"
placeholder="Field name"
/>
<SelectBox
className="h-9 min-h-9 w-[150px] text-sm"
popoverClassName="min-w-[350px]"
options={dataTypeOptions}
value={field.type.id}
valueSuffix={generateDBFieldSuffix(field)}
optionSuffix={(option) =>
generateDBFieldSuffix(field, {
databaseType,
forceExtended: true,
typeId: option.value,
})
}
onChange={(value, regexMatches) =>
onTypeChange(
field.id,
value as string,
Array.isArray(regexMatches)
? regexMatches
: undefined
)
}
placeholder="Select type"
emptyPlaceholder="No types found"
/>
<div className="flex justify-center">
<Checkbox
checked={field.primaryKey || false}
onCheckedChange={(checked) =>
onPrimaryKeyChange(field.id, checked as boolean)
}
className="size-5"
/>
</div>
<Button
variant="ghost"
size="sm"
className="size-8 p-0 hover:bg-red-100 dark:hover:bg-red-900"
onClick={() => onRemove(field.id)}
>
<Trash2 className="size-4 text-red-600" />
</Button>
</div>
);
}
);
FieldRow.displayName = 'FieldRow';
// Helper function to generate field regex patterns
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 TableEditMode: React.FC<TableEditModeProps> = memo(
({ table, color, focusFieldId, onClose }) => {
const { updateTable, databaseType, customTypes } = useChartDB();
const [tableName, setTableName] = useState(() => table.name);
const [localFields, setLocalFields] = useState<DBField[]>(() =>
(table.fields || []).map((field) => ({
...field,
primaryKey: field.primaryKey || false,
}))
);
const [removedFieldIds, setRemovedFieldIds] = useState<string[]>(
() => []
);
const [newlyCreatedFields, setNewlyCreatedFields] = useState<DBField[]>(
() => []
);
const [newFieldId, setNewFieldId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const tableNameInputRef = useRef<HTMLInputElement>(null);
const fieldInputRefs = useRef<{
[key: string]: HTMLInputElement | null;
}>({});
const hasInitialFocusedRef = useRef(false);
// Use refs to get latest state values in callbacks
const tableNameRef = useRef(tableName);
const localFieldsRef = useRef(localFields);
const removedFieldIdsRef = useRef(removedFieldIds);
const newlyCreatedFieldsRef = useRef(newlyCreatedFields);
useEffect(() => {
tableNameRef.current = tableName;
}, [tableName]);
useEffect(() => {
localFieldsRef.current = localFields;
}, [localFields]);
useEffect(() => {
removedFieldIdsRef.current = removedFieldIds;
}, [removedFieldIds]);
useEffect(() => {
newlyCreatedFieldsRef.current = newlyCreatedFields;
}, [newlyCreatedFields]);
const dataTypes = useMemo(
() =>
sortedDataTypeMap[databaseType] ||
sortedDataTypeMap[DatabaseType.GENERIC],
[databaseType]
);
// Generate options for SelectBox similar to side panel
const dataTypeOptions = useMemo(() => {
const standardTypes: SelectBoxOption[] = dataTypes.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];
}, [dataTypes, customTypes]);
// Focus on specific field when opened from pencil click, or table name when double-clicked
useEffect(() => {
// Only perform initial focus once
if (hasInitialFocusedRef.current) {
return;
}
hasInitialFocusedRef.current = true;
if (focusFieldId && scrollContainerRef.current) {
// Find the field index to calculate scroll position
const fieldIndex = localFields.findIndex(
(f) => f.id === focusFieldId
);
if (fieldIndex !== -1) {
// Scroll to the field (each field is approximately 56px height)
const scrollPosition = fieldIndex * 56;
scrollContainerRef.current.scrollTo({
top: scrollPosition,
behavior: 'smooth',
});
// Focus and select the field name after scroll
setTimeout(() => {
const input = fieldInputRefs.current[focusFieldId];
if (input) {
input.focus();
input.select();
}
}, 300);
}
} else if (!focusFieldId && tableNameInputRef.current) {
// Only focus table name on initial mount when no specific field is targeted
// This prevents focus jumping when typing in field names
setTimeout(() => {
if (tableNameInputRef.current) {
tableNameInputRef.current.focus();
tableNameInputRef.current.select();
}
}, 100);
}
}, [focusFieldId, localFields]);
// Focus and select text when a new field is added
useEffect(() => {
if (newFieldId) {
// Wait for the next render cycle and for the scroll to complete
const focusTimer = setTimeout(() => {
const input = fieldInputRefs.current[newFieldId];
if (input) {
input.focus();
// Small delay to ensure focus is set before selecting
setTimeout(() => {
input.select();
}, 50);
}
setNewFieldId(null);
}, 100);
return () => clearTimeout(focusTimer);
}
}, [newFieldId]);
// Save all changes when closing the edit mode
const saveAllChanges = useCallback(() => {
const currentTableName = tableNameRef.current;
const currentLocalFields = localFieldsRef.current;
const currentRemovedFieldIds = removedFieldIdsRef.current;
const currentNewlyCreatedFields = newlyCreatedFieldsRef.current;
// Always save to ensure field changes are persisted
// Build the final fields array with all changes
const finalFields: DBField[] = [];
// Process all fields - both existing and new
for (const field of currentLocalFields) {
const isNewField = currentNewlyCreatedFields.some(
(f) => f.id === field.id
);
if (isNewField) {
// For new fields, replace temp ID with a proper ID
finalFields.push({
...field,
id: generateId(), // Generate a proper ID for the new field
primaryKey: field.primaryKey || false,
createdAt: Date.now(),
});
} else if (!currentRemovedFieldIds.includes(field.id)) {
// Existing field that wasn't removed - ensure all properties are included
finalFields.push({
...field,
primaryKey: field.primaryKey || false,
});
}
}
// Build the update object with all changes
const tableUpdates: Partial<DBTable> = {
fields: finalFields,
};
// Add name change if needed
if (currentTableName.trim() && currentTableName !== table.name) {
tableUpdates.name = currentTableName.trim();
}
// Make a single update call with all changes
// Return the promise so we can handle it properly
return updateTable(table.id, tableUpdates);
}, [table, updateTable]);
// Save on unmount if there are changes
useEffect(() => {
return () => {
// Clean up refs
fieldInputRefs.current = {};
// Save any pending changes when component unmounts
// This ensures changes are saved even if the component is unmounted quickly
// Check if any existing fields have been modified
const fieldsModified = localFieldsRef.current.some(
(localField) => {
const originalField = table.fields.find(
(f) => f.id === localField.id
);
if (!originalField) return false; // This is a new field
// Check if any properties have changed
return (
originalField.name !== localField.name ||
(originalField.primaryKey || false) !==
(localField.primaryKey || false) ||
originalField.type.id !== localField.type.id ||
originalField.nullable !== localField.nullable ||
originalField.unique !== localField.unique
);
}
);
const hasChanges =
tableNameRef.current !== table.name ||
localFieldsRef.current.length !== table.fields.length ||
removedFieldIdsRef.current.length > 0 ||
newlyCreatedFieldsRef.current.length > 0 ||
fieldsModified;
if (hasChanges) {
// Use the refs directly since the component is unmounting
const finalFields: DBField[] = [];
for (const field of localFieldsRef.current) {
const isNewField = newlyCreatedFieldsRef.current.some(
(f) => f.id === field.id
);
if (isNewField) {
finalFields.push({
...field,
id: generateId(),
primaryKey: field.primaryKey || false,
createdAt: Date.now(),
});
} else if (
!removedFieldIdsRef.current.includes(field.id)
) {
finalFields.push({
...field,
primaryKey: field.primaryKey || false,
});
}
}
const tableUpdates: Partial<DBTable> = {
fields: finalFields,
};
if (
tableNameRef.current.trim() &&
tableNameRef.current !== table.name
) {
tableUpdates.name = tableNameRef.current.trim();
}
// Fire and forget - component is unmounting
updateTable(table.id, tableUpdates);
}
};
}, [table, updateTable]);
// Handle click outside - using both mousedown and click for better compatibility
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// Check if click is inside the edit mode container
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
// Check if the click is on a select dropdown portal element
const target = event.target as HTMLElement;
const isSelectPortal =
target.closest('[data-radix-select-viewport]') ||
target.closest('[role="listbox"]') ||
target.closest('[data-radix-popper-content-wrapper]') ||
target.closest('[data-state="open"]') ||
target.closest('[data-radix-select-content]');
// Don't close if clicking on select dropdown
if (isSelectPortal) {
return;
}
event.stopPropagation();
// Save and close - optimized approach
const savePromise = saveAllChanges();
onClose();
// Ensure save completes even after component unmounts
if (savePromise) {
savePromise.catch((error) => {
console.error(
'Failed to save table changes:',
error
);
});
}
}
};
// Prevent wheel events from propagating to the canvas
const handleWheel = (event: WheelEvent) => {
if (
containerRef.current &&
containerRef.current.contains(event.target as Node)
) {
event.stopPropagation();
}
};
// Add event listener after a very small delay to avoid immediate closing
const timer = setTimeout(() => {
// Only use mousedown to handle outside clicks
// Using both mousedown and click can cause issues with dropdowns
document.addEventListener(
'mousedown',
handleClickOutside,
true
);
document.addEventListener('wheel', handleWheel, {
passive: false,
capture: true,
});
}, 50);
return () => {
clearTimeout(timer);
document.removeEventListener(
'mousedown',
handleClickOutside,
true
);
document.removeEventListener('wheel', handleWheel, true);
};
}, [onClose, saveAllChanges]);
const handleTableNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setTableName(e.target.value);
},
[]
);
const handleFieldNameChange = useCallback(
(fieldId: string, e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value;
setLocalFields((prev) => {
const newFields = [...prev];
const index = newFields.findIndex((f) => f.id === fieldId);
if (index !== -1) {
newFields[index] = {
...newFields[index],
name: newName,
};
}
return newFields;
});
},
[]
);
const handleFieldTypeChange = useCallback(
(fieldId: string, typeId: string, regexMatches?: string[]) => {
const field = localFields.find((f) => f.id === fieldId);
if (!field) return;
const dataType = dataTypes.find((v) => v.id === typeId) ?? {
id: typeId,
name: typeId,
};
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 {
// Preserve existing values if compatible
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;
}
}
setLocalFields((prev) =>
prev.map((f) =>
f.id === fieldId
? {
...f,
type: dataTypeDataToDataType(dataType),
characterMaximumLength,
precision,
scale,
increment: undefined,
default: undefined,
}
: f
)
);
},
[dataTypes, localFields]
);
const handleFieldPrimaryKeyChange = useCallback(
(fieldId: string, primaryKey: boolean) => {
setLocalFields((prev) =>
prev.map((field) =>
field.id === fieldId
? { ...field, primaryKey: primaryKey }
: field
)
);
},
[]
);
const handleAddField = useCallback(() => {
// Create a temporary field locally without saving to database
// Default to varchar(100) if available, otherwise use first type
const varcharType = dataTypes.find(
(dt) => dt.id === 'varchar' || dt.id === 'character_varying'
);
const defaultType = varcharType ||
dataTypes[0] || { id: 'text', name: 'text' };
const tempField: DBField = {
id: `temp-${generateId()}`, // Temporary ID
name: `field${localFields.length + 1}`,
type: dataTypeDataToDataType(defaultType),
characterMaximumLength: varcharType ? '100' : undefined,
nullable: true,
unique: false,
primaryKey: false,
createdAt: Date.now(),
};
setLocalFields((prev) => [...prev, tempField]);
setNewlyCreatedFields((prev) => [...prev, tempField]);
setNewFieldId(tempField.id);
// Scroll to bottom after a minimal delay to ensure the new field is rendered
requestAnimationFrame(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth',
});
}
});
}, [localFields.length, dataTypes]);
const handleRemoveField = useCallback(
(fieldId: string) => {
// Check if this field was created in this session
const isNewField = newlyCreatedFields.some(
(f) => f.id === fieldId
);
if (isNewField) {
// Just remove from local state, it was never saved to database
setNewlyCreatedFields((prev) =>
prev.filter((f) => f.id !== fieldId)
);
setLocalFields((prev) =>
prev.filter((field) => field.id !== fieldId)
);
} else {
// Mark existing field for removal on close
setRemovedFieldIds((prev) => [...prev, fieldId]);
setLocalFields((prev) =>
prev.filter((field) => field.id !== fieldId)
);
}
},
[newlyCreatedFields]
);
// Calculate dynamic height based on number of fields
// Max height is 80% of viewport or 600px, whichever is smaller
const maxHeight = Math.min(window.innerHeight * 0.8, 600);
const calculatedHeight = 240 + localFields.length * 56; // header + fields + button + padding
const editModeHeight = Math.min(
maxHeight,
Math.max(320, calculatedHeight)
);
const isScrollable = calculatedHeight > maxHeight;
return (
<>
{/* Invisible overlay to capture clicks in the canvas */}
<div
className="fixed inset-[-9999px] z-40"
style={{ width: '99999px', height: '99999px' }}
onMouseDown={(e) => {
e.stopPropagation();
// Save and close
const savePromise = saveAllChanges();
onClose();
// Ensure save completes
if (savePromise) {
savePromise.catch((error) => {
console.error(
'Failed to save table changes:',
error
);
});
}
}}
/>
<div
ref={containerRef}
// eslint-disable-next-line tailwindcss/no-custom-classname
className="nowheel nopan nodrag absolute z-50 flex min-w-[500px] flex-col rounded-lg border-2 border-blue-500 bg-white shadow-2xl dark:bg-slate-950"
style={{
left: '-50%',
right: '-50%',
top: '50%',
transform: 'translateY(-50%)',
height: `${editModeHeight}px`,
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onPointerMove={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
>
{/* Color header bar */}
<div
className="h-2 rounded-t-[6px]"
style={{ backgroundColor: color || '#6b7280' }}
/>
<div className="flex items-center justify-between border-b bg-slate-100 p-4 dark:bg-slate-900">
<Input
ref={tableNameInputRef}
value={tableName}
onChange={handleTableNameChange}
className="mr-3 h-10 flex-1 text-base font-bold"
placeholder="Table name"
/>
<Button
variant="ghost"
size="sm"
onClick={() => {
// Save and close
const savePromise = saveAllChanges();
onClose();
// Ensure save completes
if (savePromise) {
savePromise.catch((error) => {
console.error(
'Failed to save table changes:',
error
);
});
}
}}
className="hover:bg-slate-200 dark:hover:bg-slate-800"
>
<X className="size-5" />
</Button>
</div>
<div className="relative flex flex-1 flex-col overflow-hidden">
<div className="p-4 pb-0">
{localFields.length > 0 && (
<div className="mb-3 grid grid-cols-[1fr,150px,60px,40px] gap-3 px-2 text-sm font-semibold text-slate-700 dark:text-slate-300">
<div>Field Name</div>
<div>Type</div>
<div className="text-center">PK</div>
<div></div>
</div>
)}
</div>
<div
ref={scrollContainerRef}
className={cn(
'flex-1 overflow-y-auto px-4 pr-3 custom-scrollbar relative nowheel',
isScrollable && 'pb-2'
)}
style={{
scrollbarGutter: 'stable',
overflowY:
localFields.length > 0 ? 'auto' : 'hidden',
}}
onWheel={(e) => e.stopPropagation()}
onPointerMove={(e) => e.stopPropagation()}
onMouseMove={(e) => e.stopPropagation()}
>
{localFields.length > 0 ? (
<>
{localFields.map((field) => (
<FieldRow
key={field.id}
field={field}
dataTypeOptions={dataTypeOptions}
databaseType={databaseType}
onNameChange={handleFieldNameChange}
onTypeChange={handleFieldTypeChange}
onPrimaryKeyChange={
handleFieldPrimaryKeyChange
}
onRemove={handleRemoveField}
inputRef={(el) => {
if (el)
fieldInputRefs.current[
field.id
] = el;
}}
/>
))}
</>
) : (
<div className="py-8 text-center text-base text-slate-500 dark:text-slate-400">
No fields yet. Click "Add Field" to create
one.
</div>
)}
</div>
{/* Fade overlay at bottom when scrollable */}
{isScrollable && localFields.length > 5 && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-white to-transparent dark:from-slate-950" />
)}
<div className="relative z-10 flex items-center justify-between gap-4 border-t bg-slate-50 p-4 dark:bg-slate-900">
<Button
variant="outline"
size="default"
className="h-11 max-w-[45%] flex-1 text-base"
onClick={handleAddField}
>
<Plus className="mr-2 size-5" />
Add Field
</Button>
<span className="mr-2 text-sm font-medium text-slate-600 dark:text-slate-400">
{localFields.length}{' '}
{localFields.length === 1
? 'Column'
: 'Columns'}
</span>
</div>
</div>
</div>
</>
);
}
);
TableEditMode.displayName = 'TableEditMode';

View File

@@ -19,7 +19,7 @@ import {
SquareDot, SquareDot,
SquareMinus, SquareMinus,
SquarePlus, SquarePlus,
Trash2, Pencil,
} from 'lucide-react'; } from 'lucide-react';
import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field'; import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field';
import { useChartDB } from '@/hooks/use-chartdb'; import { useChartDB } from '@/hooks/use-chartdb';
@@ -49,6 +49,7 @@ export interface TableNodeFieldProps {
highlighted: boolean; highlighted: boolean;
visible: boolean; visible: boolean;
isConnectable: boolean; isConnectable: boolean;
onOpenEditMode?: () => void;
} }
const arePropsEqual = ( const arePropsEqual = (
@@ -72,19 +73,23 @@ const arePropsEqual = (
prevProps.highlighted === nextProps.highlighted && prevProps.highlighted === nextProps.highlighted &&
prevProps.visible === nextProps.visible && prevProps.visible === nextProps.visible &&
prevProps.isConnectable === nextProps.isConnectable && prevProps.isConnectable === nextProps.isConnectable &&
prevProps.tableNodeId === nextProps.tableNodeId prevProps.tableNodeId === nextProps.tableNodeId &&
prevProps.onOpenEditMode === nextProps.onOpenEditMode
); );
}; };
export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo( export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
({ field, focused, tableNodeId, highlighted, visible, isConnectable }) => { ({
const { field,
removeField, focused,
relationships, tableNodeId,
readonly, highlighted,
updateField, visible,
highlightedCustomType, isConnectable,
} = useChartDB(); onOpenEditMode,
}) => {
const { relationships, readonly, updateField, highlightedCustomType } =
useChartDB();
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [fieldName, setFieldName] = useState(field.name); const [fieldName, setFieldName] = useState(field.name);
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
@@ -514,20 +519,21 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
)} )}
</span> </span>
</div> </div>
{readonly ? null : ( {!readonly && onOpenEditMode ? (
<div className="hidden flex-row group-hover:flex"> <div className="hidden items-center group-hover:flex">
<Button <Button
variant="ghost" variant="ghost"
className="size-6 p-0 hover:bg-primary-foreground" className="size-6 p-0.5 hover:bg-slate-200 dark:hover:bg-slate-700"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
removeField(tableNodeId, field.id); e.preventDefault();
onOpenEditMode();
}} }}
> >
<Trash2 className="size-3.5 text-red-700" /> <Pencil className="size-3.5 text-slate-600 dark:text-slate-400" />
</Button> </Button>
</div> </div>
)} ) : null}
</div> </div>
)} )}
</div> </div>

View File

@@ -6,7 +6,12 @@ import React, {
useEffect, useEffect,
} from 'react'; } from 'react';
import type { NodeProps, Node } from '@xyflow/react'; import type { NodeProps, Node } from '@xyflow/react';
import { NodeResizer, useConnection, useStore } from '@xyflow/react'; import {
NodeResizer,
useConnection,
useStore,
useReactFlow,
} from '@xyflow/react';
import { Button } from '@/components/button/button'; import { Button } from '@/components/button/button';
import { import {
ChevronsLeftRight, ChevronsLeftRight,
@@ -47,6 +52,7 @@ import {
} from '@/components/tooltip/tooltip'; } from '@/components/tooltip/tooltip';
import { useDiff } from '@/context/diff-context/use-diff'; import { useDiff } from '@/context/diff-context/use-diff';
import { TableNodeStatus } from './table-node-status/table-node-status'; import { TableNodeStatus } from './table-node-status/table-node-status';
import { TableEditMode } from './table-edit-mode';
export type TableNodeType = Node< export type TableNodeType = Node<
{ {
@@ -54,6 +60,7 @@ export type TableNodeType = Node<
isOverlapping: boolean; isOverlapping: boolean;
highlightOverlappingTables?: boolean; highlightOverlappingTables?: boolean;
hasHighlightedCustomType?: boolean; hasHighlightedCustomType?: boolean;
highlightTable?: boolean;
}, },
'table' 'table'
>; >;
@@ -68,20 +75,45 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
isOverlapping, isOverlapping,
highlightOverlappingTables, highlightOverlappingTables,
hasHighlightedCustomType, hasHighlightedCustomType,
highlightTable,
}, },
}) => { }) => {
const { updateTable, relationships, readonly } = useChartDB(); const { updateTable, relationships, readonly } = useChartDB();
const edges = useStore((store) => store.edges) as EdgeType[]; 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 [expanded, setExpanded] = useState(table.expanded ?? false);
const { t } = useTranslation(); const { t } = useTranslation();
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [tableName, setTableName] = useState(table.name); const [tableName, setTableName] = useState(table.name);
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const [isHovering, setIsHovering] = useState(false); const [isHovering, setIsHovering] = useState(false);
const [isTableEditMode, setIsTableEditMode] = useState(false);
const [focusFieldId, setFocusFieldId] = useState<string | undefined>(
undefined
);
const { setNodes } = useReactFlow();
const connection = useConnection(); const connection = useConnection();
// Update node draggable state when edit mode changes
useEffect(() => {
setNodes((nodes) =>
nodes.map((node) => {
if (node.id === id) {
return {
...node,
draggable: !isTableEditMode,
};
}
return node;
})
);
}, [isTableEditMode, id, setNodes]);
const isTarget = useMemo(() => { const isTarget = useMemo(() => {
if (!isHovering) return false; if (!isHovering) return false;
@@ -321,6 +353,9 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
hasHighlightedCustomType hasHighlightedCustomType
? 'ring-2 ring-offset-slate-50 dark:ring-offset-slate-900 ring-yellow-500 ring-offset-2 animate-scale' ? 'ring-2 ring-offset-slate-50 dark:ring-offset-slate-900 ring-yellow-500 ring-offset-2 animate-scale'
: '', : '',
highlightTable
? 'ring-2 ring-offset-slate-50 dark:ring-offset-slate-900 ring-blue-500 ring-offset-2 animate-scale-2'
: '',
isDiffTableChanged && isDiffTableChanged &&
!isSummaryOnly && !isSummaryOnly &&
!isDiffNewTable && !isDiffNewTable &&
@@ -332,18 +367,21 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
: '', : '',
isDiffTableRemoved isDiffTableRemoved
? 'outline outline-[3px] outline-red-500 dark:outline-red-900 outline-offset-[5px]' ? 'outline outline-[3px] outline-red-500 dark:outline-red-900 outline-offset-[5px]'
: '' : '',
isTableEditMode ? 'cursor-default' : ''
), ),
[ [
selected, selected,
isOverlapping, isOverlapping,
highlightOverlappingTables, highlightOverlappingTables,
hasHighlightedCustomType, hasHighlightedCustomType,
highlightTable,
isSummaryOnly, isSummaryOnly,
isDiffTableChanged, isDiffTableChanged,
isDiffNewTable, isDiffNewTable,
isDiffTableRemoved, isDiffTableRemoved,
isTarget, isTarget,
isTableEditMode,
] ]
); );
@@ -352,209 +390,277 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo(
<div <div
className={tableClassName} className={tableClassName}
onClick={(e) => { onClick={(e) => {
if (e.detail === 2) { if (e.detail === 2 && !readonly) {
openTableInEditor(); e.preventDefault();
e.stopPropagation();
setIsTableEditMode(true);
// Close sidebar after a short delay to avoid blocking the UI
setTimeout(() => {
closeAllTablesInSidebar();
}, 50);
} }
}} }}
onMouseEnter={() => setIsHovering(true)} onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)} onMouseLeave={() => setIsHovering(false)}
> >
<NodeResizer {/* Keep minimal table structure for connections even in edit mode */}
isVisible={focused} {isTableEditMode && (
lineClassName="!border-none !w-2" <>
minWidth={MIN_TABLE_SIZE} {/* Hidden fields to maintain connection handles */}
maxWidth={MAX_TABLE_SIZE} <div
shouldResize={(event) => event.dy === 0} style={{
handleClassName="!hidden" position: 'absolute',
/> opacity: 0,
<TableNodeDependencyIndicator pointerEvents: 'none',
table={table} }}
focused={focused} >
/> {table.fields.map((field: DBField) => (
<TableNodeStatus <TableNodeField
status={ key={field.id}
isDiffNewTable focused={false}
? 'new' tableNodeId={id}
: isDiffTableRemoved field={field}
? 'removed' highlighted={false}
: isDiffTableChanged && !isSummaryOnly visible={false}
? 'changed' isConnectable={!table.isView}
: 'none'
}
/>
<div
className="h-2 rounded-t-[6px]"
style={{ backgroundColor: tableColor }}
></div>
<div className="group flex h-9 items-center justify-between bg-slate-200 px-2 dark:bg-slate-900">
<div className="flex min-w-0 flex-1 items-center gap-2">
{isDiffNewTable ? (
<Tooltip>
<TooltipTrigger asChild>
<SquarePlus
className="size-3.5 shrink-0 text-green-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>New Table</TooltipContent>
</Tooltip>
) : isDiffTableRemoved ? (
<Tooltip>
<TooltipTrigger asChild>
<SquareMinus
className="size-3.5 shrink-0 text-red-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>
Table Removed
</TooltipContent>
</Tooltip>
) : isDiffTableChanged && !isSummaryOnly ? (
<Tooltip>
<TooltipTrigger asChild>
<SquareDot
className="size-3.5 shrink-0 text-sky-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>
Table Changed
</TooltipContent>
</Tooltip>
) : (
<Table2 className="size-3.5 shrink-0 text-gray-600 dark:text-primary" />
)}
{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}
</span>
<span className="mx-1 font-semibold">
</span>
<span className="truncate">
{tableChangedName}
</span>
</Label>
) : isDiffNewTable ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-green-200 px-2 py-0.5 text-sm font-normal text-green-900 dark:bg-green-800 dark:text-green-200">
{table.name}
</Label>
) : isDiffTableRemoved ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-red-200 px-2 py-0.5 text-sm font-normal text-red-900 dark:bg-red-800 dark:text-red-200">
{table.name}
</Label>
) : isDiffTableChanged && !isSummaryOnly ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
{table.name}
</Label>
) : 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" </div>
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" <TableEditMode
onClick={editTableName} table={table}
> color={tableColor}
<Check className="size-4" /> focusFieldId={focusFieldId}
</Button> onClose={() => {
</> setIsTableEditMode(false);
) : ( setFocusFieldId(undefined);
<Tooltip> }}
<TooltipTrigger asChild> />
<Label </>
className="text-editable truncate px-2 py-0.5 text-sm font-bold" )}
onDoubleClick={enterEditMode} {!isTableEditMode && (
> <>
{focused ? (
<NodeResizer
isVisible={focused}
lineClassName="!border-none !w-2"
minWidth={MIN_TABLE_SIZE}
maxWidth={MAX_TABLE_SIZE}
shouldResize={(event) => event.dy === 0}
handleClassName="!hidden"
/>
) : null}
<TableNodeDependencyIndicator
table={table}
focused={focused}
/>
<TableNodeStatus
status={
isDiffNewTable
? 'new'
: isDiffTableRemoved
? 'removed'
: isDiffTableChanged && !isSummaryOnly
? 'changed'
: 'none'
}
/>
<div
className="h-2 rounded-t-[6px]"
style={{ backgroundColor: tableColor }}
></div>
<div className="group flex h-9 items-center justify-between bg-slate-200 px-2 dark:bg-slate-900">
<div className="flex min-w-0 flex-1 items-center gap-2">
{isDiffNewTable ? (
<Tooltip>
<TooltipTrigger asChild>
<SquarePlus
className="size-3.5 shrink-0 text-green-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>
New Table
</TooltipContent>
</Tooltip>
) : isDiffTableRemoved ? (
<Tooltip>
<TooltipTrigger asChild>
<SquareMinus
className="size-3.5 shrink-0 text-red-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>
Table Removed
</TooltipContent>
</Tooltip>
) : isDiffTableChanged && !isSummaryOnly ? (
<Tooltip>
<TooltipTrigger asChild>
<SquareDot
className="size-3.5 shrink-0 text-sky-600"
strokeWidth={2.5}
/>
</TooltipTrigger>
<TooltipContent>
Table Changed
</TooltipContent>
</Tooltip>
) : (
<Table2 className="size-3.5 shrink-0 text-gray-600 dark:text-primary" />
)}
{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}
</span>
<span className="mx-1 font-semibold">
</span>
<span className="truncate">
{tableChangedName}
</span>
</Label>
) : isDiffNewTable ? (
<Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-green-200 px-2 py-0.5 text-sm font-normal text-green-900 dark:bg-green-800 dark:text-green-200">
{table.name} {table.name}
</Label> </Label>
</TooltipTrigger> ) : isDiffTableRemoved ? (
<TooltipContent> <Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-red-200 px-2 py-0.5 text-sm font-normal text-red-900 dark:bg-red-800 dark:text-red-200">
{t('tool_tips.double_click_to_edit')} {table.name}
</TooltipContent> </Label>
</Tooltip> ) : isDiffTableChanged && !isSummaryOnly ? (
)} <Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200">
</div> {table.name}
<div className="hidden shrink-0 flex-row group-hover:flex"> </Label>
{readonly || editMode ? null : ( ) : editMode && !readonly ? (
<Button <>
variant="ghost" <Input
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" ref={inputRef}
onClick={openTableInEditor} onBlur={editTableName}
> placeholder={table.name}
<CircleDotDashed className="size-4" /> autoFocus
</Button> type="text"
)} value={tableName}
{editMode ? null : ( onClick={(e) =>
<Button e.stopPropagation()
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" onChange={(e) =>
onClick={ setTableName(e.target.value)
table.width !== MAX_TABLE_SIZE }
? expandTable className="h-6 w-full border-[0.5px] border-blue-400 bg-slate-100 focus-visible:ring-0 dark:bg-slate-900"
: shrinkTable />
} <Button
> variant="ghost"
{table.width !== MAX_TABLE_SIZE ? ( 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"
<ChevronsLeftRight className="size-4" /> onClick={editTableName}
>
<Check className="size-4" />
</Button>
</>
) : ( ) : (
<ChevronsRightLeft className="size-4" /> <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>
)} )}
</Button> </div>
)} <div className="hidden shrink-0 flex-row group-hover:flex">
</div> {readonly || editMode || !focused ? null : (
</div> <Button
<div variant="ghost"
className="transition-[max-height] duration-200 ease-in-out" 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"
style={{ onClick={openTableInEditor}
maxHeight: expanded >
? `${fields.length * 2}rem` // h-8 per field <CircleDotDashed className="size-4" />
: `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field </Button>
}} )}
> {editMode || !focused ? null : (
{visibleFields.map((field: DBField) => ( <Button
<TableNodeField variant="ghost"
key={field.id} 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"
focused={focused} onClick={
tableNodeId={id} table.width !== MAX_TABLE_SIZE
field={field} ? expandTable
highlighted={highlightedFieldIds.has(field.id)} : shrinkTable
visible={true} }
isConnectable={!table.isView} >
/> {table.width !== MAX_TABLE_SIZE ? (
))} <ChevronsLeftRight className="size-4" />
</div> ) : (
{fields.length > TABLE_MINIMIZED_FIELDS && ( <ChevronsRightLeft className="size-4" />
<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" </Button>
onClick={toggleExpand} )}
> </div>
{expanded ? ( </div>
<>
<ChevronUp className="mr-1 size-3.5" /> <div className="relative">
{t('show_less')} <div
</> className="transition-[max-height] duration-200 ease-in-out"
) : ( style={{
<> maxHeight: expanded
<ChevronDown className="mr-1 size-3.5" /> ? `${fields.length * 2}rem` // h-8 per field
{t('show_more')} : `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field
</> }}
)} >
</div> {visibleFields.map((field: DBField) => (
<TableNodeField
key={field.id}
focused={focused}
tableNodeId={id}
field={field}
highlighted={highlightedFieldIds.has(
field.id
)}
visible={true}
isConnectable={!table.isView}
onOpenEditMode={() => {
if (!readonly) {
setFocusFieldId(field.id);
setIsTableEditMode(true);
setTimeout(() => {
closeAllTablesInSidebar();
}, 50);
}
}}
/>
))}
</div>
{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}
>
{expanded ? (
<>
<ChevronUp className="mr-1 size-3.5" />
{t('show_less')}
</>
) : (
<>
<ChevronDown className="mr-1 size-3.5" />
{t('show_more')}
</>
)}
</div>
)}
</div>
</>
)} )}
</div> </div>
</TableNodeContextMenu> </TableNodeContextMenu>

Some files were not shown because too many files have changed in this diff Show More