mirror of
https://github.com/chartdb/chartdb.git
synced 2025-11-05 06:23:17 +00:00
Compare commits
51 Commits
jf/fix_err
...
release-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5182f7872 | ||
|
|
9baecea4ab | ||
|
|
c8b827764c | ||
|
|
0afa71efcc | ||
|
|
69d4e8dca6 | ||
|
|
a4674a2bf8 | ||
|
|
07dc4eace0 | ||
|
|
4fd940afbb | ||
|
|
3d85bcc6ab | ||
|
|
973b7663b1 | ||
|
|
6d38ebe3ec | ||
|
|
68412f90a7 | ||
|
|
084a1d505c | ||
|
|
91e713c30a | ||
|
|
acf6d4b365 | ||
|
|
9e8979d062 | ||
|
|
9ed27cf30c | ||
|
|
2c4b344efb | ||
|
|
ccb29e0a57 | ||
|
|
7d811de097 | ||
|
|
62dec48572 | ||
|
|
49328d8fbd | ||
|
|
459698b5d0 | ||
|
|
7ad0e7712d | ||
|
|
34475add32 | ||
|
|
38fedcec0c | ||
|
|
498655e7b7 | ||
|
|
bcd8aa9378 | ||
|
|
b15bc945ac | ||
|
|
c3c646bf7c | ||
|
|
57b3b8777f | ||
|
|
bb033091b1 | ||
|
|
c9ac8929c5 | ||
|
|
c567c0a5f3 | ||
|
|
2dc1a6fc75 | ||
|
|
98f6edd5c8 | ||
|
|
47a7a73a13 | ||
|
|
d71b46e8b5 | ||
|
|
e4c4a3b354 | ||
|
|
1b8d51b73c | ||
|
|
93d72a896b | ||
|
|
9991077978 | ||
|
|
bc82f9d6a8 | ||
|
|
26dc299cd2 | ||
|
|
d6ba4a4074 | ||
|
|
d09379e8be | ||
|
|
bdc41c0b74 | ||
|
|
d3dbf41894 | ||
|
|
e6783a89cc | ||
|
|
af3638da7a | ||
|
|
8954d893bb |
88
CHANGELOG.md
88
CHANGELOG.md
@@ -1,5 +1,93 @@
|
||||
# Changelog
|
||||
|
||||
## [1.18.0](https://github.com/chartdb/chartdb/compare/v1.17.0...v1.18.0) (2025-11-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add markdown support to sticky notes ([#971](https://github.com/chartdb/chartdb/issues/971)) ([a4674a2](https://github.com/chartdb/chartdb/commit/a4674a2bf8adf988f2e67e7d99abaa293aeb3686))
|
||||
* add sticky notes ([#967](https://github.com/chartdb/chartdb/issues/967)) ([6d38ebe](https://github.com/chartdb/chartdb/commit/6d38ebe3ecd271b80c33db7e2731594a39b004d5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add postgres array type support for import and export ([#958](https://github.com/chartdb/chartdb/issues/958)) ([68412f9](https://github.com/chartdb/chartdb/commit/68412f90a7d4466946b5f20b1b31ae64708d2031))
|
||||
* Add Transactional/Analytical database categorization tabs ([#965](https://github.com/chartdb/chartdb/issues/965)) ([07dc4ea](https://github.com/chartdb/chartdb/commit/07dc4eace087d72efce88b80f8311828004f813f))
|
||||
* adjust relationship edge offset when cardinality is visible ([#973](https://github.com/chartdb/chartdb/issues/973)) ([0afa71e](https://github.com/chartdb/chartdb/commit/0afa71efccdc424930ee5615d50fe209db98151a))
|
||||
* dbml with notes ([#968](https://github.com/chartdb/chartdb/issues/968)) ([973b766](https://github.com/chartdb/chartdb/commit/973b7663b14fd0ba97e3358db9c6621663dec62c))
|
||||
* disable dragging on edit node content ([#972](https://github.com/chartdb/chartdb/issues/972)) ([69d4e8d](https://github.com/chartdb/chartdb/commit/69d4e8dca65682327b8654f58f11cf2c9a8f62cb))
|
||||
* note markdown empty note ([#974](https://github.com/chartdb/chartdb/issues/974)) ([c8b8277](https://github.com/chartdb/chartdb/commit/c8b827764c162fb440d2d410fd6b1b50dd032531))
|
||||
* note markdown empty note ([#975](https://github.com/chartdb/chartdb/issues/975)) ([9baecea](https://github.com/chartdb/chartdb/commit/9baecea4abcd39b1a584b6a4526a61261c4a68b9))
|
||||
* notes colors ([#970](https://github.com/chartdb/chartdb/issues/970)) ([4fd940a](https://github.com/chartdb/chartdb/commit/4fd940afbb33cb3306566e73c5640c6305c08a72))
|
||||
* notes with readonly ([#969](https://github.com/chartdb/chartdb/issues/969)) ([3d85bcc](https://github.com/chartdb/chartdb/commit/3d85bcc6ab862cc0a2ef6b29ae905afde88b9821))
|
||||
|
||||
## [1.17.0](https://github.com/chartdb/chartdb/compare/v1.16.0...v1.17.0) (2025-10-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* create relationships on canvas modal ([#946](https://github.com/chartdb/chartdb/issues/946)) ([34475ad](https://github.com/chartdb/chartdb/commit/34475add32f11323589ef092ccf2a8e9152ff272))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add auto-increment field detection in smart-query import ([#935](https://github.com/chartdb/chartdb/issues/935)) ([57b3b87](https://github.com/chartdb/chartdb/commit/57b3b8777fd0a445abf0ba6603faab612d469d5c))
|
||||
* add open table in editor from canvas edit ([#952](https://github.com/chartdb/chartdb/issues/952)) ([7d811de](https://github.com/chartdb/chartdb/commit/7d811de097eb11e51012772fa6bf586fd0b16c62))
|
||||
* add rels export dbml ([#937](https://github.com/chartdb/chartdb/issues/937)) ([c3c646b](https://github.com/chartdb/chartdb/commit/c3c646bf7cbb1328f4b2eb85c9a7e929f0fcd3b9))
|
||||
* add support for arrays ([#949](https://github.com/chartdb/chartdb/issues/949)) ([49328d8](https://github.com/chartdb/chartdb/commit/49328d8fbd7786f6c0c04cd5605d43a24cbf10ea))
|
||||
* add support for parsing default values in DBML ([#948](https://github.com/chartdb/chartdb/issues/948)) ([459698b](https://github.com/chartdb/chartdb/commit/459698b5d0a1ff23a3719c2e55e4ab2e2384c4fe))
|
||||
* add timestampz and int as datatypes to postgres ([#940](https://github.com/chartdb/chartdb/issues/940)) ([b15bc94](https://github.com/chartdb/chartdb/commit/b15bc945acb96d7cb3832b3b1b607dfcaef9e5ca))
|
||||
* auto-enter edit mode when creating new tables from canvas ([#943](https://github.com/chartdb/chartdb/issues/943)) ([bcd8aa9](https://github.com/chartdb/chartdb/commit/bcd8aa9378aa563f40a2b6802cc503be4c882356))
|
||||
* dbml diff fields types preview ([#934](https://github.com/chartdb/chartdb/issues/934)) ([bb03309](https://github.com/chartdb/chartdb/commit/bb033091b1f64b888822be1423a80f16f5314f6b))
|
||||
* exit table edit on area click ([#945](https://github.com/chartdb/chartdb/issues/945)) ([38fedce](https://github.com/chartdb/chartdb/commit/38fedcec0c10ea2b3f0b7fc92ca1f5ac9e540389))
|
||||
* import array fields ([#961](https://github.com/chartdb/chartdb/issues/961)) ([91e713c](https://github.com/chartdb/chartdb/commit/91e713c30a44f1ba7a767ca7816079610136fcb8))
|
||||
* manipulate schema directly from the canvas ([#947](https://github.com/chartdb/chartdb/issues/947)) ([7ad0e77](https://github.com/chartdb/chartdb/commit/7ad0e7712de975a23b2a337dc0a4a7fb4b122bd1))
|
||||
* preserve multi-word types in DBML export/import ([#956](https://github.com/chartdb/chartdb/issues/956)) ([9ed27cf](https://github.com/chartdb/chartdb/commit/9ed27cf30cca1312713e80e525138f0c27154936))
|
||||
* prevent text input glitch when editing table field names ([#944](https://github.com/chartdb/chartdb/issues/944)) ([498655e](https://github.com/chartdb/chartdb/commit/498655e7b77e57eaf641ba86263ce1ef60b93e16))
|
||||
* resolve canvas filter tree state issues ([#953](https://github.com/chartdb/chartdb/issues/953)) ([ccb29e0](https://github.com/chartdb/chartdb/commit/ccb29e0a574dfa4cfdf0ebf242a4c4aaa48cc37b))
|
||||
* resolve dbml increment & nullable attributes issue ([#954](https://github.com/chartdb/chartdb/issues/954)) ([2c4b344](https://github.com/chartdb/chartdb/commit/2c4b344efb24041e7f607fc6124e109b69aaa457))
|
||||
* show SQL Script option conditionally for databases without DDL support ([#960](https://github.com/chartdb/chartdb/issues/960)) ([acf6d4b](https://github.com/chartdb/chartdb/commit/acf6d4b3654d8868b8a8ebf717c608d9749b71da))
|
||||
* use flag for custom types ([#951](https://github.com/chartdb/chartdb/issues/951)) ([62dec48](https://github.com/chartdb/chartdb/commit/62dec4857211b705a8039691da1772263ea986fe))
|
||||
|
||||
## [1.16.0](https://github.com/chartdb/chartdb/compare/v1.15.1...v1.16.0) (2025-09-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add area context menu and UI improvements ([#918](https://github.com/chartdb/chartdb/issues/918)) ([d09379e](https://github.com/chartdb/chartdb/commit/d09379e8be0fa3c83ca77ff62ae815fe4db9869b))
|
||||
* add quick table mode on canvas ([#915](https://github.com/chartdb/chartdb/issues/915)) ([8954d89](https://github.com/chartdb/chartdb/commit/8954d893bbfee45bb311380115fb14ebbf3a3133))
|
||||
* add zoom navigation buttons to canvas filter for tables and areas ([#903](https://github.com/chartdb/chartdb/issues/903)) ([a0fb1ed](https://github.com/chartdb/chartdb/commit/a0fb1ed08ba18b66354fa3498d610097a83d4afc))
|
||||
* **import-db:** add DBML syntax to import database dialog ([#768](https://github.com/chartdb/chartdb/issues/768)) ([af3638d](https://github.com/chartdb/chartdb/commit/af3638da7a9b70f281ceaddbc2f712a713d90cda))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add areas width and height + table width to diff check ([#931](https://github.com/chartdb/chartdb/issues/931)) ([98f6edd](https://github.com/chartdb/chartdb/commit/98f6edd5c8a8e9130e892b2d841744e0cf63a7bf))
|
||||
* add diff x,y ([#928](https://github.com/chartdb/chartdb/issues/928)) ([e4c4a3b](https://github.com/chartdb/chartdb/commit/e4c4a3b35484d9ece955a5aec577603dde73d634))
|
||||
* add support for ALTER TABLE ADD COLUMN in PostgreSQL importer ([#892](https://github.com/chartdb/chartdb/issues/892)) ([ec6e46f](https://github.com/chartdb/chartdb/commit/ec6e46fe81ea1806c179c50a4c5779d8596008aa))
|
||||
* add tests for diff ([#930](https://github.com/chartdb/chartdb/issues/930)) ([47a7a73](https://github.com/chartdb/chartdb/commit/47a7a73a137b87dfa6e67aff5f939cf64ccf4601))
|
||||
* dbml edit mode glitch ([#925](https://github.com/chartdb/chartdb/issues/925)) ([93d72a8](https://github.com/chartdb/chartdb/commit/93d72a896bab9aa79d8ea2f876126887e432214c))
|
||||
* dbml export default time bug ([#922](https://github.com/chartdb/chartdb/issues/922)) ([bc82f9d](https://github.com/chartdb/chartdb/commit/bc82f9d6a8fe4de2f7e0fc465e0a20c5dbf8f41d))
|
||||
* dbml export renaming fields bug ([#921](https://github.com/chartdb/chartdb/issues/921)) ([26dc299](https://github.com/chartdb/chartdb/commit/26dc299cd28e9890d191c13f84a15ac38ae48b11))
|
||||
* **dbml:** export array fields without quotes ([#911](https://github.com/chartdb/chartdb/issues/911)) ([5e81c18](https://github.com/chartdb/chartdb/commit/5e81c1848aaa911990e1e881d62525f5254d6d34))
|
||||
* diff logic ([#927](https://github.com/chartdb/chartdb/issues/927)) ([1b8d51b](https://github.com/chartdb/chartdb/commit/1b8d51b73c4ed4b7c5929adcb17a44927c7defca))
|
||||
* export dbml issues after upgrade version ([#883](https://github.com/chartdb/chartdb/issues/883)) ([07937a2](https://github.com/chartdb/chartdb/commit/07937a2f51708b1c10b45c2bd1f9a9acf5c3f708))
|
||||
* export sql + import metadata lib ([#902](https://github.com/chartdb/chartdb/issues/902)) ([ffddcdc](https://github.com/chartdb/chartdb/commit/ffddcdcc987bacb0e0d7e8dea27d08d3a8c5a8c8))
|
||||
* handle bidirectional relationships in DBML export ([#924](https://github.com/chartdb/chartdb/issues/924)) ([9991077](https://github.com/chartdb/chartdb/commit/99910779789a9c6ef113d06bc3de31e35b9b04d1))
|
||||
* import dbml set pk field unique ([#920](https://github.com/chartdb/chartdb/issues/920)) ([d6ba4a4](https://github.com/chartdb/chartdb/commit/d6ba4a40749d85d2703f120600df4345dab3c561))
|
||||
* improve SQL default value parsing for PostgreSQL, MySQL, and SQL Server with proper type handling and casting support ([#900](https://github.com/chartdb/chartdb/issues/900)) ([fe9ef27](https://github.com/chartdb/chartdb/commit/fe9ef275b8619dcfd7e57541a62a6237a16d29a8))
|
||||
* move area utils ([#932](https://github.com/chartdb/chartdb/issues/932)) ([2dc1a6f](https://github.com/chartdb/chartdb/commit/2dc1a6fc7519e0a455b0e1306601195deb156c96))
|
||||
* move auto arrange to toolbar ([#904](https://github.com/chartdb/chartdb/issues/904)) ([b016a70](https://github.com/chartdb/chartdb/commit/b016a70691bc22af5720b4de683e8c9353994fcc))
|
||||
* remove general db creation ([#901](https://github.com/chartdb/chartdb/issues/901)) ([df89f0b](https://github.com/chartdb/chartdb/commit/df89f0b6b9ba3fcc8b05bae4f60c0dc4ad1d2215))
|
||||
* remove many to many rel option ([#933](https://github.com/chartdb/chartdb/issues/933)) ([c567c0a](https://github.com/chartdb/chartdb/commit/c567c0a5f39157b2c430e92192b6750304d7a834))
|
||||
* reset increment and default when change field ([#896](https://github.com/chartdb/chartdb/issues/896)) ([e5e1d59](https://github.com/chartdb/chartdb/commit/e5e1d5932762422ea63acfd6cf9fe4f03aa822f7))
|
||||
* **sql-import:** handle SQL Server DDL with multiple tables, inline foreign keys, and case-insensitive field matching ([#897](https://github.com/chartdb/chartdb/issues/897)) ([2a64dee](https://github.com/chartdb/chartdb/commit/2a64deebb87a11ee3892024c3273d682bb86f7ef))
|
||||
* **sql-import:** support ALTER TABLE ALTER COLUMN TYPE in PostgreSQL importer ([#895](https://github.com/chartdb/chartdb/issues/895)) ([aa29061](https://github.com/chartdb/chartdb/commit/aa290615caf806d7d0374c848d50b4636fde7e96))
|
||||
* **sqlite:** improve parser to handle tables without column types and fix column detection ([#914](https://github.com/chartdb/chartdb/issues/914)) ([d3dbf41](https://github.com/chartdb/chartdb/commit/d3dbf41894d74f0ffce9afe3bd810f065aa53017))
|
||||
* trigger edit table on canvas from context menu ([#919](https://github.com/chartdb/chartdb/issues/919)) ([bdc41c0](https://github.com/chartdb/chartdb/commit/bdc41c0b74d9d9918e7b6cd2152fa07c0c58ce60))
|
||||
* update deps vulns ([#909](https://github.com/chartdb/chartdb/issues/909)) ([2bd9ca2](https://github.com/chartdb/chartdb/commit/2bd9ca25b2c7b1f053ff4fdc8c5cfc1b0e65901d))
|
||||
* upgrade dbml lib ([#880](https://github.com/chartdb/chartdb/issues/880)) ([d8e0bc7](https://github.com/chartdb/chartdb/commit/d8e0bc7db8881971ddaea7177bcebee13cc865f6))
|
||||
|
||||
## [1.15.1](https://github.com/chartdb/chartdb/compare/v1.15.0...v1.15.1) (2025-08-27)
|
||||
|
||||
|
||||
|
||||
1646
package-lock.json
generated
1646
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chartdb",
|
||||
"private": true,
|
||||
"version": "1.15.1",
|
||||
"version": "1.18.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -64,10 +64,13 @@
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.0.22",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-use": "^17.5.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"timeago-react": "^3.0.6",
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface CodeSnippetProps {
|
||||
className?: string;
|
||||
code: string;
|
||||
codeToCopy?: string;
|
||||
language?: 'sql' | 'shell';
|
||||
language?: 'sql' | 'shell' | 'dbml';
|
||||
loading?: boolean;
|
||||
autoScroll?: boolean;
|
||||
isComplete?: boolean;
|
||||
|
||||
@@ -9,12 +9,14 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'comment', foreground: '6A9955' }, // Comments
|
||||
{ token: 'keyword', foreground: '569CD6' }, // Table, Ref keywords
|
||||
{ token: 'string', foreground: 'CE9178' }, // Strings
|
||||
{ token: 'annotation', foreground: '9CDCFE' }, // [annotations]
|
||||
{ token: 'delimiter', foreground: 'D4D4D4' }, // Braces {}
|
||||
{ token: 'operator', foreground: 'D4D4D4' }, // Operators
|
||||
{ token: 'datatype', foreground: '4EC9B0' }, // Data types
|
||||
{ token: 'type', foreground: '4EC9B0' }, // Data types
|
||||
{ token: 'identifier', foreground: '9CDCFE' }, // Field names
|
||||
],
|
||||
colors: {},
|
||||
});
|
||||
@@ -23,12 +25,14 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'comment', foreground: '008000' }, // Comments
|
||||
{ token: 'keyword', foreground: '0000FF' }, // Table, Ref keywords
|
||||
{ token: 'string', foreground: 'A31515' }, // Strings
|
||||
{ token: 'annotation', foreground: '001080' }, // [annotations]
|
||||
{ token: 'delimiter', foreground: '000000' }, // Braces {}
|
||||
{ token: 'operator', foreground: '000000' }, // Operators
|
||||
{ token: 'type', foreground: '267F99' }, // Data types
|
||||
{ token: 'identifier', foreground: '001080' }, // Field names
|
||||
],
|
||||
colors: {},
|
||||
});
|
||||
@@ -37,23 +41,59 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
|
||||
const datatypePattern = dataTypesNames.join('|');
|
||||
|
||||
monaco.languages.setMonarchTokensProvider('dbml', {
|
||||
keywords: ['Table', 'Ref', 'Indexes', 'Note', 'Enum'],
|
||||
keywords: ['Table', 'Ref', 'Indexes', 'Note', 'Enum', 'enum'],
|
||||
datatypes: dataTypesNames,
|
||||
operators: ['>', '<', '-'],
|
||||
|
||||
tokenizer: {
|
||||
root: [
|
||||
// Comments
|
||||
[/\/\/.*$/, 'comment'],
|
||||
|
||||
// Keywords - case insensitive
|
||||
[
|
||||
/\b([Tt][Aa][Bb][Ll][Ee]|[Ee][Nn][Uu][Mm]|[Rr][Ee][Ff]|[Ii][Nn][Dd][Ee][Xx][Ee][Ss]|[Nn][Oo][Tt][Ee])\b/,
|
||||
'keyword',
|
||||
],
|
||||
|
||||
// Annotations in brackets
|
||||
[/\[.*?\]/, 'annotation'],
|
||||
|
||||
// Strings
|
||||
[/'''/, 'string', '@tripleQuoteString'],
|
||||
[/".*?"/, 'string'],
|
||||
[/'.*?'/, 'string'],
|
||||
[/"([^"\\]|\\.)*$/, 'string.invalid'], // non-terminated string
|
||||
[/'([^'\\]|\\.)*$/, 'string.invalid'], // non-terminated string
|
||||
[/"/, 'string', '@string_double'],
|
||||
[/'/, 'string', '@string_single'],
|
||||
[/`.*?`/, 'string'],
|
||||
[/[{}]/, 'delimiter'],
|
||||
[/[<>]/, 'operator'],
|
||||
[new RegExp(`\\b(${datatypePattern})\\b`, 'i'), 'type'], // Added 'i' flag for case-insensitive matching
|
||||
|
||||
// Delimiters and operators
|
||||
[/[{}()]/, 'delimiter'],
|
||||
[/[<>-]/, 'operator'],
|
||||
[/:/, 'delimiter'],
|
||||
|
||||
// Data types
|
||||
[new RegExp(`\\b(${datatypePattern})\\b`, 'i'), 'type'],
|
||||
|
||||
// Numbers
|
||||
[/\d+/, 'number'],
|
||||
|
||||
// Identifiers
|
||||
[/[a-zA-Z_]\w*/, 'identifier'],
|
||||
],
|
||||
|
||||
string_double: [
|
||||
[/[^\\"]+/, 'string'],
|
||||
[/\\./, 'string.escape'],
|
||||
[/"/, 'string', '@pop'],
|
||||
],
|
||||
|
||||
string_single: [
|
||||
[/[^\\']+/, 'string'],
|
||||
[/\\./, 'string.escape'],
|
||||
[/'/, 'string', '@pop'],
|
||||
],
|
||||
|
||||
tripleQuoteString: [
|
||||
[/[^']+/, 'string'],
|
||||
[/'''/, 'string', '@pop'],
|
||||
|
||||
@@ -11,12 +11,14 @@ export interface ColorPickerProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
disabled?: boolean;
|
||||
popoverOnMouseDown?: (e: React.MouseEvent) => void;
|
||||
popoverOnClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const ColorPicker = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverTrigger>,
|
||||
ColorPickerProps
|
||||
>(({ color, onChange, disabled }, ref) => {
|
||||
>(({ color, onChange, disabled, popoverOnMouseDown, popoverOnClick }, ref) => {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
@@ -37,7 +39,11 @@ export const ColorPicker = React.forwardRef<
|
||||
}}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit">
|
||||
<PopoverContent
|
||||
className="w-fit"
|
||||
onMouseDown={popoverOnMouseDown}
|
||||
onClick={popoverOnClick}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{colorOptions.map((option) => (
|
||||
<div
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import EmptyStateImage from '@/assets/empty_state.png';
|
||||
import EmptyStateImageDark from '@/assets/empty_state_dark.png';
|
||||
import { Label } from '@/components/label/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '../empty/empty';
|
||||
|
||||
export interface EmptyStateProps {
|
||||
title: string;
|
||||
@@ -38,26 +45,29 @@ export const EmptyState = forwardRef<
|
||||
className
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
effectiveTheme === 'dark'
|
||||
? EmptyStateImageDark
|
||||
: EmptyStateImage
|
||||
}
|
||||
alt="Empty state"
|
||||
className={cn('mb-2 w-20', imageClassName)}
|
||||
/>
|
||||
<Label className={cn('text-base', titleClassName)}>
|
||||
{title}
|
||||
</Label>
|
||||
<Label
|
||||
className={cn(
|
||||
'text-sm text-center font-normal text-muted-foreground',
|
||||
descriptionClassName
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</Label>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
{/* <Group /> */}
|
||||
<img
|
||||
src={
|
||||
effectiveTheme === 'dark'
|
||||
? EmptyStateImageDark
|
||||
: EmptyStateImage
|
||||
}
|
||||
alt="Empty state"
|
||||
className={cn('p-2', imageClassName)}
|
||||
/>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className={titleClassName}>
|
||||
{title}
|
||||
</EmptyTitle>
|
||||
<EmptyDescription className={descriptionClassName}>
|
||||
{description}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent />
|
||||
</Empty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
105
src/components/empty/empty.tsx
Normal file
105
src/components/empty/empty.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils/index';
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
'flex max-w-sm flex-col items-center gap-2 text-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn('text-lg font-medium tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
'flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
};
|
||||
@@ -56,6 +56,9 @@ export interface SelectBoxProps {
|
||||
popoverClassName?: string;
|
||||
readonly?: boolean;
|
||||
footerButtons?: React.ReactNode;
|
||||
commandOnMouseDown?: (e: React.MouseEvent) => void;
|
||||
commandOnClick?: (e: React.MouseEvent) => void;
|
||||
onSearchChange?: (search: string) => void;
|
||||
}
|
||||
|
||||
export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
@@ -83,6 +86,9 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
popoverClassName,
|
||||
readonly,
|
||||
footerButtons,
|
||||
commandOnMouseDown,
|
||||
commandOnClick,
|
||||
onSearchChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -236,6 +242,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
<CommandItem
|
||||
className="flex items-center"
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
keywords={option.regex ? [option.regex] : undefined}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
@@ -243,6 +250,8 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
matches?.map((match) => match?.toString())
|
||||
)
|
||||
}
|
||||
onMouseDown={commandOnMouseDown}
|
||||
onClick={commandOnClick}
|
||||
>
|
||||
{multiple && (
|
||||
<div
|
||||
@@ -288,7 +297,15 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
</CommandItem>
|
||||
);
|
||||
},
|
||||
[value, multiple, searchTerm, handleSelect, optionSuffix]
|
||||
[
|
||||
value,
|
||||
multiple,
|
||||
searchTerm,
|
||||
handleSelect,
|
||||
optionSuffix,
|
||||
commandOnClick,
|
||||
commandOnMouseDown,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -366,6 +383,8 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
popoverClassName
|
||||
)}
|
||||
align="center"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Command
|
||||
filter={(value, search, keywords) => {
|
||||
@@ -388,7 +407,10 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
value={searchTerm}
|
||||
onValueChange={(e) => setSearchTerm(e)}
|
||||
onValueChange={(e) => {
|
||||
setSearchTerm(e);
|
||||
onSearchChange?.(e);
|
||||
}}
|
||||
ref={ref}
|
||||
placeholder={inputPlaceholder ?? 'Search...'}
|
||||
className="h-9"
|
||||
|
||||
@@ -42,6 +42,7 @@ interface TreeViewProps<
|
||||
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
|
||||
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
|
||||
loadingNodeIds?: string[];
|
||||
disableCache?: boolean;
|
||||
}
|
||||
|
||||
export function TreeView<
|
||||
@@ -62,12 +63,14 @@ export function TreeView<
|
||||
renderHoverComponent,
|
||||
renderActionsComponent,
|
||||
loadingNodeIds,
|
||||
disableCache = false,
|
||||
}: TreeViewProps<Type, Context>) {
|
||||
const { expanded, loading, loadedChildren, hasMoreChildren, toggleNode } =
|
||||
useTree({
|
||||
fetchChildren,
|
||||
expanded: expandedProp,
|
||||
setExpanded: setExpandedProp,
|
||||
disableCache,
|
||||
});
|
||||
const [selectedIdInternal, setSelectedIdInternal] = React.useState<
|
||||
string | undefined
|
||||
@@ -145,6 +148,7 @@ export function TreeView<
|
||||
renderHoverComponent={renderHoverComponent}
|
||||
renderActionsComponent={renderActionsComponent}
|
||||
loadingNodeIds={loadingNodeIds}
|
||||
disableCache={disableCache}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -179,6 +183,7 @@ interface TreeNodeProps<
|
||||
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
|
||||
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
|
||||
loadingNodeIds?: string[];
|
||||
disableCache?: boolean;
|
||||
}
|
||||
|
||||
function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
|
||||
@@ -201,11 +206,16 @@ function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
|
||||
renderHoverComponent,
|
||||
renderActionsComponent,
|
||||
loadingNodeIds,
|
||||
disableCache = false,
|
||||
}: TreeNodeProps<Type, Context>) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isExpanded = expanded[node.id];
|
||||
const isLoading = loading[node.id];
|
||||
const children = loadedChildren[node.id] || node.children;
|
||||
// If cache is disabled, always use fresh node.children
|
||||
// Otherwise, use cached loadedChildren if available (for async fetched data)
|
||||
const children = disableCache
|
||||
? node.children
|
||||
: node.children || loadedChildren[node.id];
|
||||
const isSelected = selectedId === node.id;
|
||||
|
||||
const IconComponent =
|
||||
@@ -423,6 +433,7 @@ function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
|
||||
renderHoverComponent={renderHoverComponent}
|
||||
renderActionsComponent={renderActionsComponent}
|
||||
loadingNodeIds={loadingNodeIds}
|
||||
disableCache={disableCache}
|
||||
/>
|
||||
))}
|
||||
{isLoading ? (
|
||||
|
||||
@@ -28,10 +28,12 @@ export function useTree<
|
||||
fetchChildren,
|
||||
expanded: expandedProp,
|
||||
setExpanded: setExpandedProp,
|
||||
disableCache = false,
|
||||
}: {
|
||||
fetchChildren?: FetchChildrenFunction<Type, Context>;
|
||||
expanded?: ExpandedState;
|
||||
setExpanded?: Dispatch<SetStateAction<ExpandedState>>;
|
||||
disableCache?: boolean;
|
||||
}) {
|
||||
const [expandedInternal, setExpandedInternal] = useState<ExpandedState>({});
|
||||
|
||||
@@ -89,8 +91,8 @@ export function useTree<
|
||||
// Get any previously fetched children
|
||||
const previouslyFetchedChildren = loadedChildren[nodeId] || [];
|
||||
|
||||
// If we have static children, merge them with any previously fetched children
|
||||
if (staticChildren?.length) {
|
||||
// Only cache if caching is enabled
|
||||
if (!disableCache && staticChildren?.length) {
|
||||
const mergedChildren = mergeChildren(
|
||||
staticChildren,
|
||||
previouslyFetchedChildren
|
||||
@@ -110,8 +112,8 @@ export function useTree<
|
||||
// Set expanded state immediately to show static/previously fetched children
|
||||
setExpanded((prev) => ({ ...prev, [nodeId]: true }));
|
||||
|
||||
// If we haven't loaded dynamic children yet
|
||||
if (!previouslyFetchedChildren.length) {
|
||||
// If we haven't loaded dynamic children yet and cache is enabled
|
||||
if (!disableCache && !previouslyFetchedChildren.length) {
|
||||
setLoading((prev) => ({ ...prev, [nodeId]: true }));
|
||||
try {
|
||||
const fetchedChildren = await fetchChildren?.(
|
||||
@@ -140,7 +142,14 @@ export function useTree<
|
||||
}
|
||||
}
|
||||
},
|
||||
[expanded, loadedChildren, fetchChildren, mergeChildren, setExpanded]
|
||||
[
|
||||
expanded,
|
||||
loadedChildren,
|
||||
fetchChildren,
|
||||
mergeChildren,
|
||||
setExpanded,
|
||||
disableCache,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,24 @@ import { createContext } from 'react';
|
||||
import { emptyFn } from '@/lib/utils';
|
||||
import type { Graph } from '@/lib/graph';
|
||||
import { createGraph } from '@/lib/graph';
|
||||
import { EventEmitter } from 'ahooks/lib/useEventEmitter';
|
||||
|
||||
export type CanvasEventType = 'pan_click';
|
||||
|
||||
export type CanvasEventBase<T extends CanvasEventType, D> = {
|
||||
action: T;
|
||||
data: D;
|
||||
};
|
||||
|
||||
export type PanClickEvent = CanvasEventBase<
|
||||
'pan_click',
|
||||
{
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
>;
|
||||
|
||||
export type CanvasEvent = PanClickEvent;
|
||||
|
||||
export interface CanvasContext {
|
||||
reorderTables: (options?: { updateHistory?: boolean }) => void;
|
||||
@@ -14,6 +32,42 @@ export interface CanvasContext {
|
||||
overlapGraph: Graph<string>;
|
||||
setShowFilter: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showFilter: boolean;
|
||||
editTableModeTable: {
|
||||
tableId: string;
|
||||
fieldId?: string;
|
||||
} | null;
|
||||
setEditTableModeTable: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
tableId: string;
|
||||
fieldId?: string;
|
||||
} | null>
|
||||
>;
|
||||
tempFloatingEdge: {
|
||||
sourceNodeId: string;
|
||||
targetNodeId?: string;
|
||||
} | null;
|
||||
setTempFloatingEdge: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
sourceNodeId: string;
|
||||
targetNodeId?: string;
|
||||
} | null>
|
||||
>;
|
||||
startFloatingEdgeCreation: ({
|
||||
sourceNodeId,
|
||||
}: {
|
||||
sourceNodeId: string;
|
||||
}) => void;
|
||||
endFloatingEdgeCreation: () => void;
|
||||
hoveringTableId: string | null;
|
||||
setHoveringTableId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
showCreateRelationshipNode: (params: {
|
||||
sourceTableId: string;
|
||||
targetTableId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}) => void;
|
||||
hideCreateRelationshipNode: () => void;
|
||||
events: EventEmitter<CanvasEvent>;
|
||||
}
|
||||
|
||||
export const canvasContext = createContext<CanvasContext>({
|
||||
@@ -23,4 +77,15 @@ export const canvasContext = createContext<CanvasContext>({
|
||||
overlapGraph: createGraph(),
|
||||
setShowFilter: emptyFn,
|
||||
showFilter: false,
|
||||
editTableModeTable: null,
|
||||
setEditTableModeTable: emptyFn,
|
||||
tempFloatingEdge: null,
|
||||
setTempFloatingEdge: emptyFn,
|
||||
startFloatingEdgeCreation: emptyFn,
|
||||
endFloatingEdgeCreation: emptyFn,
|
||||
hoveringTableId: null,
|
||||
setHoveringTableId: emptyFn,
|
||||
showCreateRelationshipNode: emptyFn,
|
||||
hideCreateRelationshipNode: emptyFn,
|
||||
events: new EventEmitter(),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import type { CanvasContext, CanvasEvent } from './canvas-context';
|
||||
import { canvasContext } from './canvas-context';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { adjustTablePositions } from '@/lib/domain/db-table';
|
||||
@@ -15,6 +16,11 @@ import { createGraph } from '@/lib/graph';
|
||||
import { useDiagramFilter } from '../diagram-filter-context/use-diagram-filter';
|
||||
import { filterTable } from '@/lib/domain/diagram-filter/filter';
|
||||
import { defaultSchemas } from '@/lib/data/default-schemas';
|
||||
import {
|
||||
CREATE_RELATIONSHIP_NODE_ID,
|
||||
type CreateRelationshipNodeType,
|
||||
} from '@/pages/editor-page/canvas/create-relationship-node/create-relationship-node';
|
||||
import { useEventEmitter } from 'ahooks';
|
||||
|
||||
interface CanvasProviderProps {
|
||||
children: ReactNode;
|
||||
@@ -30,11 +36,23 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
|
||||
diagramId,
|
||||
} = useChartDB();
|
||||
const { filter, loading: filterLoading } = useDiagramFilter();
|
||||
const { fitView } = useReactFlow();
|
||||
const { fitView, screenToFlowPosition, setNodes } = useReactFlow();
|
||||
const [overlapGraph, setOverlapGraph] =
|
||||
useState<Graph<string>>(createGraph());
|
||||
const [editTableModeTable, setEditTableModeTable] = useState<{
|
||||
tableId: string;
|
||||
fieldId?: string;
|
||||
} | null>(null);
|
||||
|
||||
const events = useEventEmitter<CanvasEvent>();
|
||||
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
|
||||
const [tempFloatingEdge, setTempFloatingEdge] =
|
||||
useState<CanvasContext['tempFloatingEdge']>(null);
|
||||
|
||||
const [hoveringTableId, setHoveringTableId] = useState<string | null>(null);
|
||||
|
||||
const diagramIdActiveFilterRef = useRef<string>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -118,6 +136,66 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
|
||||
]
|
||||
);
|
||||
|
||||
const startFloatingEdgeCreation: CanvasContext['startFloatingEdgeCreation'] =
|
||||
useCallback(({ sourceNodeId }) => {
|
||||
setShowFilter(false);
|
||||
setTempFloatingEdge({
|
||||
sourceNodeId,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const endFloatingEdgeCreation: CanvasContext['endFloatingEdgeCreation'] =
|
||||
useCallback(() => {
|
||||
setTempFloatingEdge(null);
|
||||
}, []);
|
||||
|
||||
const hideCreateRelationshipNode: CanvasContext['hideCreateRelationshipNode'] =
|
||||
useCallback(() => {
|
||||
setNodes((nds) =>
|
||||
nds.filter((n) => n.id !== CREATE_RELATIONSHIP_NODE_ID)
|
||||
);
|
||||
endFloatingEdgeCreation();
|
||||
}, [setNodes, endFloatingEdgeCreation]);
|
||||
|
||||
const showCreateRelationshipNode: CanvasContext['showCreateRelationshipNode'] =
|
||||
useCallback(
|
||||
({ sourceTableId, targetTableId, x, y }) => {
|
||||
setTempFloatingEdge((edge) =>
|
||||
edge
|
||||
? {
|
||||
...edge,
|
||||
targetNodeId: targetTableId,
|
||||
}
|
||||
: null
|
||||
);
|
||||
const cursorPos = screenToFlowPosition({
|
||||
x,
|
||||
y,
|
||||
});
|
||||
|
||||
const newNode: CreateRelationshipNodeType = {
|
||||
id: CREATE_RELATIONSHIP_NODE_ID,
|
||||
type: 'create-relationship',
|
||||
position: cursorPos,
|
||||
data: {
|
||||
sourceTableId,
|
||||
targetTableId,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
setNodes((nds) => {
|
||||
const nodesWithoutOldCreateRelationshipNode = nds.filter(
|
||||
(n) => n.id !== CREATE_RELATIONSHIP_NODE_ID
|
||||
);
|
||||
return [...nodesWithoutOldCreateRelationshipNode, newNode];
|
||||
});
|
||||
},
|
||||
[screenToFlowPosition, setNodes]
|
||||
);
|
||||
|
||||
return (
|
||||
<canvasContext.Provider
|
||||
value={{
|
||||
@@ -127,6 +205,17 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
|
||||
overlapGraph,
|
||||
setShowFilter,
|
||||
showFilter,
|
||||
editTableModeTable,
|
||||
setEditTableModeTable,
|
||||
tempFloatingEdge: tempFloatingEdge,
|
||||
setTempFloatingEdge: setTempFloatingEdge,
|
||||
startFloatingEdgeCreation: startFloatingEdgeCreation,
|
||||
endFloatingEdgeCreation: endFloatingEdgeCreation,
|
||||
hoveringTableId,
|
||||
setHoveringTableId,
|
||||
showCreateRelationshipNode,
|
||||
hideCreateRelationshipNode,
|
||||
events,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { DBDependency } from '@/lib/domain/db-dependency';
|
||||
import { EventEmitter } from 'ahooks/lib/useEventEmitter';
|
||||
import type { Area } from '@/lib/domain/area';
|
||||
import type { DBCustomType } from '@/lib/domain/db-custom-type';
|
||||
import type { Note } from '@/lib/domain/note';
|
||||
|
||||
export type ChartDBEventType =
|
||||
| 'add_tables'
|
||||
@@ -74,6 +75,7 @@ export interface ChartDBContext {
|
||||
dependencies: DBDependency[];
|
||||
areas: Area[];
|
||||
customTypes: DBCustomType[];
|
||||
notes: Note[];
|
||||
currentDiagram: Diagram;
|
||||
events: EventEmitter<ChartDBEvent>;
|
||||
readonly?: boolean;
|
||||
@@ -255,6 +257,31 @@ export interface ChartDBContext {
|
||||
options?: { updateHistory: boolean }
|
||||
) => Promise<void>;
|
||||
|
||||
// Note operations
|
||||
createNote: (attributes?: Partial<Omit<Note, 'id'>>) => Promise<Note>;
|
||||
addNote: (
|
||||
note: Note,
|
||||
options?: { updateHistory: boolean }
|
||||
) => Promise<void>;
|
||||
addNotes: (
|
||||
notes: Note[],
|
||||
options?: { updateHistory: boolean }
|
||||
) => Promise<void>;
|
||||
getNote: (id: string) => Note | null;
|
||||
removeNote: (
|
||||
id: string,
|
||||
options?: { updateHistory: boolean }
|
||||
) => Promise<void>;
|
||||
removeNotes: (
|
||||
ids: string[],
|
||||
options?: { updateHistory: boolean }
|
||||
) => Promise<void>;
|
||||
updateNote: (
|
||||
id: string,
|
||||
note: Partial<Note>,
|
||||
options?: { updateHistory: boolean }
|
||||
) => Promise<void>;
|
||||
|
||||
// Custom type operations
|
||||
createCustomType: (
|
||||
attributes?: Partial<Omit<DBCustomType, 'id'>>
|
||||
@@ -292,6 +319,7 @@ export const chartDBContext = createContext<ChartDBContext>({
|
||||
dependencies: [],
|
||||
areas: [],
|
||||
customTypes: [],
|
||||
notes: [],
|
||||
schemas: [],
|
||||
highlightCustomTypeId: emptyFn,
|
||||
currentDiagram: {
|
||||
@@ -368,6 +396,15 @@ export const chartDBContext = createContext<ChartDBContext>({
|
||||
removeAreas: emptyFn,
|
||||
updateArea: emptyFn,
|
||||
|
||||
// Note operations
|
||||
createNote: emptyFn,
|
||||
addNote: emptyFn,
|
||||
addNotes: emptyFn,
|
||||
getNote: emptyFn,
|
||||
removeNote: emptyFn,
|
||||
removeNotes: emptyFn,
|
||||
updateNote: emptyFn,
|
||||
|
||||
// Custom type operations
|
||||
createCustomType: emptyFn,
|
||||
addCustomType: emptyFn,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { defaultSchemas } from '@/lib/data/default-schemas';
|
||||
import { useEventEmitter } from 'ahooks';
|
||||
import type { DBDependency } from '@/lib/domain/db-dependency';
|
||||
import type { Area } from '@/lib/domain/area';
|
||||
import type { Note } from '@/lib/domain/note';
|
||||
import { storageInitialValue } from '../storage-context/storage-context';
|
||||
import { useDiff } from '../diff-context/use-diff';
|
||||
import type { DiffCalculatedEvent } from '../diff-context/diff-context';
|
||||
@@ -67,6 +68,7 @@ export const ChartDBProvider: React.FC<
|
||||
const [customTypes, setCustomTypes] = useState<DBCustomType[]>(
|
||||
diagram?.customTypes ?? []
|
||||
);
|
||||
const [notes, setNotes] = useState<Note[]>(diagram?.notes ?? []);
|
||||
|
||||
const { events: diffEvents } = useDiff();
|
||||
|
||||
@@ -74,10 +76,10 @@ export const ChartDBProvider: React.FC<
|
||||
useState<string>();
|
||||
|
||||
const diffCalculatedHandler = useCallback((event: DiffCalculatedEvent) => {
|
||||
const { tablesAdded, fieldsAdded, relationshipsAdded } = event.data;
|
||||
const { tablesToAdd, fieldsToAdd, relationshipsToAdd } = event.data;
|
||||
setTables((tables) =>
|
||||
[...tables, ...(tablesAdded ?? [])].map((table) => {
|
||||
const fields = fieldsAdded.get(table.id);
|
||||
[...tables, ...(tablesToAdd ?? [])].map((table) => {
|
||||
const fields = fieldsToAdd.get(table.id);
|
||||
return fields
|
||||
? { ...table, fields: [...table.fields, ...fields] }
|
||||
: table;
|
||||
@@ -85,7 +87,7 @@ export const ChartDBProvider: React.FC<
|
||||
);
|
||||
setRelationships((relationships) => [
|
||||
...relationships,
|
||||
...(relationshipsAdded ?? []),
|
||||
...(relationshipsToAdd ?? []),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
@@ -147,6 +149,7 @@ export const ChartDBProvider: React.FC<
|
||||
dependencies,
|
||||
areas,
|
||||
customTypes,
|
||||
notes,
|
||||
}),
|
||||
[
|
||||
diagramId,
|
||||
@@ -158,6 +161,7 @@ export const ChartDBProvider: React.FC<
|
||||
dependencies,
|
||||
areas,
|
||||
customTypes,
|
||||
notes,
|
||||
diagramCreatedAt,
|
||||
diagramUpdatedAt,
|
||||
]
|
||||
@@ -171,6 +175,7 @@ export const ChartDBProvider: React.FC<
|
||||
setDependencies([]);
|
||||
setAreas([]);
|
||||
setCustomTypes([]);
|
||||
setNotes([]);
|
||||
setDiagramUpdatedAt(updatedAt);
|
||||
|
||||
resetRedoStack();
|
||||
@@ -183,6 +188,7 @@ export const ChartDBProvider: React.FC<
|
||||
db.deleteDiagramDependencies(diagramId),
|
||||
db.deleteDiagramAreas(diagramId),
|
||||
db.deleteDiagramCustomTypes(diagramId),
|
||||
db.deleteDiagramNotes(diagramId),
|
||||
]);
|
||||
}, [db, diagramId, resetRedoStack, resetUndoStack]);
|
||||
|
||||
@@ -197,6 +203,7 @@ export const ChartDBProvider: React.FC<
|
||||
setDependencies([]);
|
||||
setAreas([]);
|
||||
setCustomTypes([]);
|
||||
setNotes([]);
|
||||
resetRedoStack();
|
||||
resetUndoStack();
|
||||
|
||||
@@ -207,6 +214,7 @@ export const ChartDBProvider: React.FC<
|
||||
db.deleteDiagramDependencies(diagramId),
|
||||
db.deleteDiagramAreas(diagramId),
|
||||
db.deleteDiagramCustomTypes(diagramId),
|
||||
db.deleteDiagramNotes(diagramId),
|
||||
]);
|
||||
}, [db, diagramId, resetRedoStack, resetUndoStack]);
|
||||
|
||||
@@ -350,6 +358,7 @@ export const ChartDBProvider: React.FC<
|
||||
isView: false,
|
||||
order: tables.length,
|
||||
...attributes,
|
||||
schema: attributes?.schema ?? defaultSchemas[databaseType],
|
||||
};
|
||||
|
||||
table.indexes = getTableIndexesWithPrimaryKey({
|
||||
@@ -1527,6 +1536,130 @@ export const ChartDBProvider: React.FC<
|
||||
[db, diagramId, setAreas, getArea, addUndoAction, resetRedoStack]
|
||||
);
|
||||
|
||||
// Note operations
|
||||
const addNotes: ChartDBContext['addNotes'] = useCallback(
|
||||
async (notes: Note[], options = { updateHistory: true }) => {
|
||||
setNotes((currentNotes) => [...currentNotes, ...notes]);
|
||||
|
||||
const updatedAt = new Date();
|
||||
setDiagramUpdatedAt(updatedAt);
|
||||
|
||||
await Promise.all([
|
||||
...notes.map((note) => db.addNote({ diagramId, note })),
|
||||
db.updateDiagram({ id: diagramId, attributes: { updatedAt } }),
|
||||
]);
|
||||
|
||||
if (options.updateHistory) {
|
||||
addUndoAction({
|
||||
action: 'addNotes',
|
||||
redoData: { notes },
|
||||
undoData: { noteIds: notes.map((n) => n.id) },
|
||||
});
|
||||
resetRedoStack();
|
||||
}
|
||||
},
|
||||
[db, diagramId, setNotes, addUndoAction, resetRedoStack]
|
||||
);
|
||||
|
||||
const addNote: ChartDBContext['addNote'] = useCallback(
|
||||
async (note: Note, options = { updateHistory: true }) => {
|
||||
return addNotes([note], options);
|
||||
},
|
||||
[addNotes]
|
||||
);
|
||||
|
||||
const createNote: ChartDBContext['createNote'] = useCallback(
|
||||
async (attributes) => {
|
||||
const note: Note = {
|
||||
id: generateId(),
|
||||
content: '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 150,
|
||||
color: '#ffe374', // Default warm yellow
|
||||
...attributes,
|
||||
};
|
||||
|
||||
await addNote(note);
|
||||
|
||||
return note;
|
||||
},
|
||||
[addNote]
|
||||
);
|
||||
|
||||
const getNote: ChartDBContext['getNote'] = useCallback(
|
||||
(id: string) => notes.find((note) => note.id === id) ?? null,
|
||||
[notes]
|
||||
);
|
||||
|
||||
const removeNotes: ChartDBContext['removeNotes'] = useCallback(
|
||||
async (ids: string[], options = { updateHistory: true }) => {
|
||||
const prevNotes = [
|
||||
...notes.filter((note) => ids.includes(note.id)),
|
||||
];
|
||||
|
||||
setNotes((notes) => notes.filter((note) => !ids.includes(note.id)));
|
||||
|
||||
const updatedAt = new Date();
|
||||
setDiagramUpdatedAt(updatedAt);
|
||||
|
||||
await Promise.all([
|
||||
...ids.map((id) => db.deleteNote({ diagramId, id })),
|
||||
db.updateDiagram({ id: diagramId, attributes: { updatedAt } }),
|
||||
]);
|
||||
|
||||
if (prevNotes.length > 0 && options.updateHistory) {
|
||||
addUndoAction({
|
||||
action: 'removeNotes',
|
||||
redoData: { noteIds: ids },
|
||||
undoData: { notes: prevNotes },
|
||||
});
|
||||
resetRedoStack();
|
||||
}
|
||||
},
|
||||
[db, diagramId, setNotes, notes, addUndoAction, resetRedoStack]
|
||||
);
|
||||
|
||||
const removeNote: ChartDBContext['removeNote'] = useCallback(
|
||||
async (id: string, options = { updateHistory: true }) => {
|
||||
return removeNotes([id], options);
|
||||
},
|
||||
[removeNotes]
|
||||
);
|
||||
|
||||
const updateNote: ChartDBContext['updateNote'] = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
note: Partial<Note>,
|
||||
options = { updateHistory: true }
|
||||
) => {
|
||||
const prevNote = getNote(id);
|
||||
|
||||
setNotes((notes) =>
|
||||
notes.map((n) => (n.id === id ? { ...n, ...note } : n))
|
||||
);
|
||||
|
||||
const updatedAt = new Date();
|
||||
setDiagramUpdatedAt(updatedAt);
|
||||
|
||||
await Promise.all([
|
||||
db.updateDiagram({ id: diagramId, attributes: { updatedAt } }),
|
||||
db.updateNote({ id, attributes: note }),
|
||||
]);
|
||||
|
||||
if (!!prevNote && options.updateHistory) {
|
||||
addUndoAction({
|
||||
action: 'updateNote',
|
||||
redoData: { noteId: id, note },
|
||||
undoData: { noteId: id, note: prevNote },
|
||||
});
|
||||
resetRedoStack();
|
||||
}
|
||||
},
|
||||
[db, diagramId, setNotes, getNote, addUndoAction, resetRedoStack]
|
||||
);
|
||||
|
||||
const highlightCustomTypeId = useCallback(
|
||||
(id?: string) => setHighlightedCustomTypeId(id),
|
||||
[setHighlightedCustomTypeId]
|
||||
@@ -1553,6 +1686,7 @@ export const ChartDBProvider: React.FC<
|
||||
setDiagramCreatedAt(diagram.createdAt);
|
||||
setDiagramUpdatedAt(diagram.updatedAt);
|
||||
setHighlightedCustomTypeId(undefined);
|
||||
setNotes(diagram.notes ?? []);
|
||||
|
||||
events.emit({ action: 'load_diagram', data: { diagram } });
|
||||
|
||||
@@ -1573,6 +1707,7 @@ export const ChartDBProvider: React.FC<
|
||||
setDiagramUpdatedAt,
|
||||
setHighlightedCustomTypeId,
|
||||
events,
|
||||
setNotes,
|
||||
resetRedoStack,
|
||||
resetUndoStack,
|
||||
]
|
||||
@@ -1596,6 +1731,7 @@ export const ChartDBProvider: React.FC<
|
||||
includeDependencies: true,
|
||||
includeAreas: true,
|
||||
includeCustomTypes: true,
|
||||
includeNotes: true,
|
||||
});
|
||||
|
||||
if (diagram) {
|
||||
@@ -1761,6 +1897,7 @@ export const ChartDBProvider: React.FC<
|
||||
relationships,
|
||||
dependencies,
|
||||
areas,
|
||||
notes,
|
||||
currentDiagram,
|
||||
schemas,
|
||||
events,
|
||||
@@ -1824,6 +1961,13 @@ export const ChartDBProvider: React.FC<
|
||||
updateCustomType,
|
||||
highlightCustomTypeId,
|
||||
highlightedCustomType,
|
||||
createNote,
|
||||
addNote,
|
||||
addNotes,
|
||||
getNote,
|
||||
removeNote,
|
||||
removeNotes,
|
||||
updateNote,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { ExportImageDialogProps } from '@/dialogs/export-image-dialog/expor
|
||||
import type { ExportDiagramDialogProps } from '@/dialogs/export-diagram-dialog/export-diagram-dialog';
|
||||
import type { ImportDiagramDialogProps } from '@/dialogs/import-diagram-dialog/import-diagram-dialog';
|
||||
import type { CreateRelationshipDialogProps } from '@/dialogs/create-relationship-dialog/create-relationship-dialog';
|
||||
import type { ImportDBMLDialogProps } from '@/dialogs/import-dbml-dialog/import-dbml-dialog';
|
||||
import type { OpenDiagramDialogProps } from '@/dialogs/open-diagram-dialog/open-diagram-dialog';
|
||||
import type { CreateDiagramDialogProps } from '@/dialogs/create-diagram-dialog/create-diagram-dialog';
|
||||
|
||||
@@ -67,12 +66,6 @@ export interface DialogContext {
|
||||
params: Omit<ImportDiagramDialogProps, 'dialog'>
|
||||
) => void;
|
||||
closeImportDiagramDialog: () => void;
|
||||
|
||||
// Import DBML dialog
|
||||
openImportDBMLDialog: (
|
||||
params?: Omit<ImportDBMLDialogProps, 'dialog'>
|
||||
) => void;
|
||||
closeImportDBMLDialog: () => void;
|
||||
}
|
||||
|
||||
export const dialogContext = createContext<DialogContext>({
|
||||
@@ -96,6 +89,4 @@ export const dialogContext = createContext<DialogContext>({
|
||||
closeExportDiagramDialog: emptyFn,
|
||||
openImportDiagramDialog: emptyFn,
|
||||
closeImportDiagramDialog: emptyFn,
|
||||
openImportDBMLDialog: emptyFn,
|
||||
closeImportDBMLDialog: emptyFn,
|
||||
});
|
||||
|
||||
@@ -20,8 +20,6 @@ import type { ExportImageDialogProps } from '@/dialogs/export-image-dialog/expor
|
||||
import { ExportImageDialog } from '@/dialogs/export-image-dialog/export-image-dialog';
|
||||
import { ExportDiagramDialog } from '@/dialogs/export-diagram-dialog/export-diagram-dialog';
|
||||
import { ImportDiagramDialog } from '@/dialogs/import-diagram-dialog/import-diagram-dialog';
|
||||
import type { ImportDBMLDialogProps } from '@/dialogs/import-dbml-dialog/import-dbml-dialog';
|
||||
import { ImportDBMLDialog } from '@/dialogs/import-dbml-dialog/import-dbml-dialog';
|
||||
|
||||
export const DialogProvider: React.FC<React.PropsWithChildren> = ({
|
||||
children,
|
||||
@@ -132,11 +130,6 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const [openImportDiagramDialog, setOpenImportDiagramDialog] =
|
||||
useState(false);
|
||||
|
||||
// Import DBML dialog
|
||||
const [openImportDBMLDialog, setOpenImportDBMLDialog] = useState(false);
|
||||
const [importDBMLDialogParams, setImportDBMLDialogParams] =
|
||||
useState<Omit<ImportDBMLDialogProps, 'dialog'>>();
|
||||
|
||||
return (
|
||||
<dialogContext.Provider
|
||||
value={{
|
||||
@@ -165,11 +158,6 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({
|
||||
openImportDiagramDialog: () => setOpenImportDiagramDialog(true),
|
||||
closeImportDiagramDialog: () =>
|
||||
setOpenImportDiagramDialog(false),
|
||||
openImportDBMLDialog: (params) => {
|
||||
setImportDBMLDialogParams(params);
|
||||
setOpenImportDBMLDialog(true);
|
||||
},
|
||||
closeImportDBMLDialog: () => setOpenImportDBMLDialog(false),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -204,10 +192,6 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({
|
||||
/>
|
||||
<ExportDiagramDialog dialog={{ open: openExportDiagramDialog }} />
|
||||
<ImportDiagramDialog dialog={{ open: openImportDiagramDialog }} />
|
||||
<ImportDBMLDialog
|
||||
dialog={{ open: openImportDBMLDialog }}
|
||||
{...importDBMLDialogParams}
|
||||
/>
|
||||
</dialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,9 +15,9 @@ export type DiffEventBase<T extends DiffEventType, D> = {
|
||||
};
|
||||
|
||||
export type DiffCalculatedData = {
|
||||
tablesAdded: DBTable[];
|
||||
fieldsAdded: Map<string, DBField[]>;
|
||||
relationshipsAdded: DBRelationship[];
|
||||
tablesToAdd: DBTable[];
|
||||
fieldsToAdd: Map<string, DBField[]>;
|
||||
relationshipsToAdd: DBRelationship[];
|
||||
};
|
||||
|
||||
export type DiffCalculatedEvent = DiffEventBase<
|
||||
@@ -44,15 +44,21 @@ export interface DiffContext {
|
||||
options?: {
|
||||
summaryOnly?: boolean;
|
||||
};
|
||||
}) => void;
|
||||
}) => { foundDiff: boolean };
|
||||
resetDiff: () => void;
|
||||
|
||||
// table diff
|
||||
checkIfTableHasChange: ({ tableId }: { tableId: string }) => boolean;
|
||||
checkIfNewTable: ({ tableId }: { tableId: string }) => boolean;
|
||||
checkIfTableRemoved: ({ tableId }: { tableId: string }) => boolean;
|
||||
getTableNewName: ({ tableId }: { tableId: string }) => string | null;
|
||||
getTableNewColor: ({ tableId }: { tableId: string }) => string | null;
|
||||
getTableNewName: ({ tableId }: { tableId: string }) => {
|
||||
old: string;
|
||||
new: string;
|
||||
} | null;
|
||||
getTableNewColor: ({ tableId }: { tableId: string }) => {
|
||||
old: string;
|
||||
new: string;
|
||||
} | null;
|
||||
|
||||
// field diff
|
||||
checkIfFieldHasChange: ({
|
||||
@@ -64,17 +70,46 @@ export interface DiffContext {
|
||||
}) => boolean;
|
||||
checkIfFieldRemoved: ({ fieldId }: { fieldId: string }) => boolean;
|
||||
checkIfNewField: ({ fieldId }: { fieldId: string }) => boolean;
|
||||
getFieldNewName: ({ fieldId }: { fieldId: string }) => string | null;
|
||||
getFieldNewType: ({ fieldId }: { fieldId: string }) => DataType | null;
|
||||
getFieldNewPrimaryKey: ({ fieldId }: { fieldId: string }) => boolean | null;
|
||||
getFieldNewNullable: ({ fieldId }: { fieldId: string }) => boolean | null;
|
||||
getFieldNewName: ({
|
||||
fieldId,
|
||||
}: {
|
||||
fieldId: string;
|
||||
}) => { old: string; new: string } | null;
|
||||
getFieldNewType: ({
|
||||
fieldId,
|
||||
}: {
|
||||
fieldId: string;
|
||||
}) => { old: DataType; new: DataType } | null;
|
||||
getFieldNewPrimaryKey: ({
|
||||
fieldId,
|
||||
}: {
|
||||
fieldId: string;
|
||||
}) => { old: boolean; new: boolean } | null;
|
||||
getFieldNewNullable: ({
|
||||
fieldId,
|
||||
}: {
|
||||
fieldId: string;
|
||||
}) => { old: boolean; new: boolean } | null;
|
||||
getFieldNewCharacterMaximumLength: ({
|
||||
fieldId,
|
||||
}: {
|
||||
fieldId: string;
|
||||
}) => string | null;
|
||||
getFieldNewScale: ({ fieldId }: { fieldId: string }) => number | null;
|
||||
getFieldNewPrecision: ({ fieldId }: { fieldId: string }) => number | null;
|
||||
}) => { old: string; new: string } | null;
|
||||
getFieldNewScale: ({
|
||||
fieldId,
|
||||
}: {
|
||||
fieldId: string;
|
||||
}) => { old: number; new: number } | null;
|
||||
getFieldNewPrecision: ({
|
||||
fieldId,
|
||||
}: {
|
||||
fieldId: string;
|
||||
}) => { old: number; new: number } | null;
|
||||
getFieldNewIsArray: ({
|
||||
fieldId,
|
||||
}: {
|
||||
fieldId: string;
|
||||
}) => { old: boolean; new: boolean } | null;
|
||||
|
||||
// relationship diff
|
||||
checkIfNewRelationship: ({
|
||||
|
||||
@@ -36,7 +36,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
|
||||
const events = useEventEmitter<DiffEvent>();
|
||||
|
||||
const generateNewFieldsMap = useCallback(
|
||||
const generateFieldsToAddMap = useCallback(
|
||||
({
|
||||
diffMap,
|
||||
newDiagram,
|
||||
@@ -66,7 +66,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
[]
|
||||
);
|
||||
|
||||
const findNewRelationships = useCallback(
|
||||
const findRelationshipsToAdd = useCallback(
|
||||
({
|
||||
diffMap,
|
||||
newDiagram,
|
||||
@@ -101,7 +101,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
diffMap: DiffMap;
|
||||
}): DiffCalculatedData => {
|
||||
return {
|
||||
tablesAdded:
|
||||
tablesToAdd:
|
||||
newDiagram?.tables?.filter((table) => {
|
||||
const tableKey = getDiffMapKey({
|
||||
diffObject: 'table',
|
||||
@@ -114,17 +114,17 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
);
|
||||
}) ?? [],
|
||||
|
||||
fieldsAdded: generateNewFieldsMap({
|
||||
fieldsToAdd: generateFieldsToAddMap({
|
||||
diffMap: diffMap,
|
||||
newDiagram: newDiagram,
|
||||
}),
|
||||
relationshipsAdded: findNewRelationships({
|
||||
relationshipsToAdd: findRelationshipsToAdd({
|
||||
diffMap: diffMap,
|
||||
newDiagram: newDiagram,
|
||||
}),
|
||||
};
|
||||
},
|
||||
[findNewRelationships, generateNewFieldsMap]
|
||||
[findRelationshipsToAdd, generateFieldsToAddMap]
|
||||
);
|
||||
|
||||
const calculateDiff: DiffContext['calculateDiff'] = useCallback(
|
||||
@@ -149,6 +149,8 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
newDiagram: newDiagramArg,
|
||||
}),
|
||||
});
|
||||
|
||||
return { foundDiff: !!newDiffs.size };
|
||||
},
|
||||
[setDiffMap, events, generateDiffCalculatedData]
|
||||
);
|
||||
@@ -165,7 +167,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const diff = diffMap.get(tableNameKey);
|
||||
|
||||
if (diff?.type === 'changed') {
|
||||
return diff.newValue as string;
|
||||
return {
|
||||
new: diff.newValue as string,
|
||||
old: diff.oldValue as string,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +191,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const diff = diffMap.get(tableColorKey);
|
||||
|
||||
if (diff?.type === 'changed') {
|
||||
return diff.newValue as string;
|
||||
return {
|
||||
new: diff.newValue as string,
|
||||
old: diff.oldValue as string,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -277,7 +285,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const diff = diffMap.get(fieldKey);
|
||||
|
||||
if (diff?.type === 'changed') {
|
||||
return diff.newValue as string;
|
||||
return {
|
||||
old: diff.oldValue as string,
|
||||
new: diff.newValue as string,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +309,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const diff = diffMap.get(fieldKey);
|
||||
|
||||
if (diff?.type === 'changed') {
|
||||
return diff.newValue as DataType;
|
||||
return {
|
||||
old: diff.oldValue as DataType,
|
||||
new: diff.newValue as DataType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +335,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const diff = diffMap.get(fieldKey);
|
||||
|
||||
if (diff?.type === 'changed') {
|
||||
return diff.newValue as boolean;
|
||||
return {
|
||||
old: diff.oldValue as boolean,
|
||||
new: diff.newValue as boolean,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,7 +359,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const diff = diffMap.get(fieldKey);
|
||||
|
||||
if (diff?.type === 'changed') {
|
||||
return diff.newValue as boolean;
|
||||
return {
|
||||
old: diff.oldValue as boolean,
|
||||
new: diff.newValue as boolean,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +385,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const diff = diffMap.get(fieldKey);
|
||||
|
||||
if (diff?.type === 'changed') {
|
||||
return diff.newValue as string;
|
||||
return {
|
||||
old: diff.oldValue as string,
|
||||
new: diff.newValue as string,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +409,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const diff = diffMap.get(fieldKey);
|
||||
|
||||
if (diff?.type === 'changed') {
|
||||
return diff.newValue as number;
|
||||
return {
|
||||
old: diff.oldValue as number,
|
||||
new: diff.newValue as number,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +435,34 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const diff = diffMap.get(fieldKey);
|
||||
|
||||
if (diff?.type === 'changed') {
|
||||
return diff.newValue as number;
|
||||
return {
|
||||
old: diff.oldValue as number,
|
||||
new: diff.newValue as number,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[diffMap]
|
||||
);
|
||||
|
||||
const getFieldNewIsArray = useCallback<DiffContext['getFieldNewIsArray']>(
|
||||
({ fieldId }) => {
|
||||
const fieldKey = getDiffMapKey({
|
||||
diffObject: 'field',
|
||||
objectId: fieldId,
|
||||
attribute: 'isArray',
|
||||
});
|
||||
|
||||
if (diffMap.has(fieldKey)) {
|
||||
const diff = diffMap.get(fieldKey);
|
||||
|
||||
if (diff?.type === 'changed') {
|
||||
return {
|
||||
old: diff.oldValue as boolean,
|
||||
new: diff.newValue as boolean,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +544,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
|
||||
getFieldNewCharacterMaximumLength,
|
||||
getFieldNewScale,
|
||||
getFieldNewPrecision,
|
||||
getFieldNewIsArray,
|
||||
|
||||
// relationship diff
|
||||
checkIfNewRelationship,
|
||||
|
||||
@@ -39,6 +39,9 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
|
||||
addCustomTypes,
|
||||
removeCustomTypes,
|
||||
updateCustomType,
|
||||
addNotes,
|
||||
removeNotes,
|
||||
updateNote,
|
||||
} = useChartDB();
|
||||
|
||||
const redoActionHandlers = useMemo(
|
||||
@@ -135,6 +138,15 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
|
||||
updateHistory: false,
|
||||
});
|
||||
},
|
||||
addNotes: ({ redoData: { notes } }) => {
|
||||
return addNotes(notes, { updateHistory: false });
|
||||
},
|
||||
removeNotes: ({ redoData: { noteIds } }) => {
|
||||
return removeNotes(noteIds, { updateHistory: false });
|
||||
},
|
||||
updateNote: ({ redoData: { noteId, note } }) => {
|
||||
return updateNote(noteId, note, { updateHistory: false });
|
||||
},
|
||||
}),
|
||||
[
|
||||
addTables,
|
||||
@@ -160,6 +172,9 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
|
||||
addCustomTypes,
|
||||
removeCustomTypes,
|
||||
updateCustomType,
|
||||
addNotes,
|
||||
removeNotes,
|
||||
updateNote,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -271,6 +286,15 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
|
||||
updateHistory: false,
|
||||
});
|
||||
},
|
||||
addNotes: ({ undoData: { noteIds } }) => {
|
||||
return removeNotes(noteIds, { updateHistory: false });
|
||||
},
|
||||
removeNotes: ({ undoData: { notes } }) => {
|
||||
return addNotes(notes, { updateHistory: false });
|
||||
},
|
||||
updateNote: ({ undoData: { noteId, note } }) => {
|
||||
return updateNote(noteId, note, { updateHistory: false });
|
||||
},
|
||||
}),
|
||||
[
|
||||
addTables,
|
||||
@@ -296,6 +320,9 @@ export const HistoryProvider: React.FC<React.PropsWithChildren> = ({
|
||||
addCustomTypes,
|
||||
removeCustomTypes,
|
||||
updateCustomType,
|
||||
addNotes,
|
||||
removeNotes,
|
||||
updateNote,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { DBRelationship } from '@/lib/domain/db-relationship';
|
||||
import type { DBDependency } from '@/lib/domain/db-dependency';
|
||||
import type { Area } from '@/lib/domain/area';
|
||||
import type { DBCustomType } from '@/lib/domain/db-custom-type';
|
||||
import type { Note } from '@/lib/domain/note';
|
||||
|
||||
type Action = keyof ChartDBContext;
|
||||
|
||||
@@ -161,6 +162,24 @@ type RedoUndoActionRemoveCustomTypes = RedoUndoActionBase<
|
||||
{ customTypes: DBCustomType[] }
|
||||
>;
|
||||
|
||||
type RedoUndoActionAddNotes = RedoUndoActionBase<
|
||||
'addNotes',
|
||||
{ notes: Note[] },
|
||||
{ noteIds: string[] }
|
||||
>;
|
||||
|
||||
type RedoUndoActionUpdateNote = RedoUndoActionBase<
|
||||
'updateNote',
|
||||
{ noteId: string; note: Partial<Note> },
|
||||
{ noteId: string; note: Partial<Note> }
|
||||
>;
|
||||
|
||||
type RedoUndoActionRemoveNotes = RedoUndoActionBase<
|
||||
'removeNotes',
|
||||
{ noteIds: string[] },
|
||||
{ notes: Note[] }
|
||||
>;
|
||||
|
||||
export type RedoUndoAction =
|
||||
| RedoUndoActionAddTables
|
||||
| RedoUndoActionRemoveTables
|
||||
@@ -184,7 +203,10 @@ export type RedoUndoAction =
|
||||
| RedoUndoActionRemoveAreas
|
||||
| RedoUndoActionAddCustomTypes
|
||||
| RedoUndoActionUpdateCustomType
|
||||
| RedoUndoActionRemoveCustomTypes;
|
||||
| RedoUndoActionRemoveCustomTypes
|
||||
| RedoUndoActionAddNotes
|
||||
| RedoUndoActionUpdateNote
|
||||
| RedoUndoActionRemoveNotes;
|
||||
|
||||
export type RedoActionData<T extends Action> = Extract<
|
||||
RedoUndoAction,
|
||||
|
||||
@@ -5,8 +5,10 @@ export type SidebarSection =
|
||||
| 'dbml'
|
||||
| 'tables'
|
||||
| 'refs'
|
||||
| 'areas'
|
||||
| 'customTypes';
|
||||
| 'customTypes'
|
||||
| 'visuals';
|
||||
|
||||
export type VisualsTab = 'areas' | 'notes';
|
||||
|
||||
export interface LayoutContext {
|
||||
openedTableInSidebar: string | undefined;
|
||||
@@ -27,6 +29,10 @@ export interface LayoutContext {
|
||||
openAreaFromSidebar: (areaId: string) => void;
|
||||
closeAllAreasInSidebar: () => void;
|
||||
|
||||
openedNoteInSidebar: string | undefined;
|
||||
openNoteFromSidebar: (noteId: string) => void;
|
||||
closeAllNotesInSidebar: () => void;
|
||||
|
||||
openedCustomTypeInSidebar: string | undefined;
|
||||
openCustomTypeFromSidebar: (customTypeId: string) => void;
|
||||
closeAllCustomTypesInSidebar: () => void;
|
||||
@@ -34,6 +40,9 @@ export interface LayoutContext {
|
||||
selectedSidebarSection: SidebarSection;
|
||||
selectSidebarSection: (section: SidebarSection) => void;
|
||||
|
||||
selectedVisualsTab: VisualsTab;
|
||||
selectVisualsTab: (tab: VisualsTab) => void;
|
||||
|
||||
isSidePanelShowed: boolean;
|
||||
hideSidePanel: () => void;
|
||||
showSidePanel: () => void;
|
||||
@@ -58,6 +67,10 @@ export const layoutContext = createContext<LayoutContext>({
|
||||
openAreaFromSidebar: emptyFn,
|
||||
closeAllAreasInSidebar: emptyFn,
|
||||
|
||||
openedNoteInSidebar: undefined,
|
||||
openNoteFromSidebar: emptyFn,
|
||||
closeAllNotesInSidebar: emptyFn,
|
||||
|
||||
openedCustomTypeInSidebar: undefined,
|
||||
openCustomTypeFromSidebar: emptyFn,
|
||||
closeAllCustomTypesInSidebar: emptyFn,
|
||||
@@ -66,6 +79,9 @@ export const layoutContext = createContext<LayoutContext>({
|
||||
openTableFromSidebar: emptyFn,
|
||||
closeAllTablesInSidebar: emptyFn,
|
||||
|
||||
selectedVisualsTab: 'areas',
|
||||
selectVisualsTab: emptyFn,
|
||||
|
||||
isSidePanelShowed: false,
|
||||
hideSidePanel: emptyFn,
|
||||
showSidePanel: emptyFn,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React from 'react';
|
||||
import type { LayoutContext, SidebarSection } from './layout-context';
|
||||
import type {
|
||||
LayoutContext,
|
||||
SidebarSection,
|
||||
VisualsTab,
|
||||
} from './layout-context';
|
||||
import { layoutContext } from './layout-context';
|
||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
|
||||
@@ -16,10 +20,15 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const [openedAreaInSidebar, setOpenedAreaInSidebar] = React.useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [openedNoteInSidebar, setOpenedNoteInSidebar] = React.useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [openedCustomTypeInSidebar, setOpenedCustomTypeInSidebar] =
|
||||
React.useState<string | undefined>();
|
||||
const [selectedSidebarSection, setSelectedSidebarSection] =
|
||||
React.useState<SidebarSection>('tables');
|
||||
const [selectedVisualsTab, setSelectedVisualsTab] =
|
||||
React.useState<VisualsTab>('areas');
|
||||
const [isSidePanelShowed, setIsSidePanelShowed] =
|
||||
React.useState<boolean>(isDesktop);
|
||||
|
||||
@@ -38,6 +47,9 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
|
||||
const closeAllAreasInSidebar: LayoutContext['closeAllAreasInSidebar'] =
|
||||
() => setOpenedAreaInSidebar('');
|
||||
|
||||
const closeAllNotesInSidebar: LayoutContext['closeAllNotesInSidebar'] =
|
||||
() => setOpenedNoteInSidebar('');
|
||||
|
||||
const closeAllCustomTypesInSidebar: LayoutContext['closeAllCustomTypesInSidebar'] =
|
||||
() => setOpenedCustomTypeInSidebar('');
|
||||
|
||||
@@ -83,10 +95,20 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
|
||||
areaId
|
||||
) => {
|
||||
showSidePanel();
|
||||
setSelectedSidebarSection('areas');
|
||||
setSelectedSidebarSection('visuals');
|
||||
setSelectedVisualsTab('areas');
|
||||
setOpenedAreaInSidebar(areaId);
|
||||
};
|
||||
|
||||
const openNoteFromSidebar: LayoutContext['openNoteFromSidebar'] = (
|
||||
noteId
|
||||
) => {
|
||||
showSidePanel();
|
||||
setSelectedSidebarSection('visuals');
|
||||
setSelectedVisualsTab('notes');
|
||||
setOpenedNoteInSidebar(noteId);
|
||||
};
|
||||
|
||||
const openCustomTypeFromSidebar: LayoutContext['openCustomTypeFromSidebar'] =
|
||||
(customTypeId) => {
|
||||
showSidePanel();
|
||||
@@ -116,9 +138,14 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
|
||||
openedAreaInSidebar,
|
||||
openAreaFromSidebar,
|
||||
closeAllAreasInSidebar,
|
||||
openedNoteInSidebar,
|
||||
openNoteFromSidebar,
|
||||
closeAllNotesInSidebar,
|
||||
openedCustomTypeInSidebar,
|
||||
openCustomTypeFromSidebar,
|
||||
closeAllCustomTypesInSidebar,
|
||||
selectedVisualsTab,
|
||||
selectVisualsTab: setSelectedVisualsTab,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { DBDependency } from '@/lib/domain/db-dependency';
|
||||
import type { Area } from '@/lib/domain/area';
|
||||
import type { DBCustomType } from '@/lib/domain/db-custom-type';
|
||||
import type { DiagramFilter } from '@/lib/domain/diagram-filter/diagram-filter';
|
||||
import type { Note } from '@/lib/domain/note';
|
||||
|
||||
export interface StorageContext {
|
||||
// Config operations
|
||||
@@ -30,6 +31,7 @@ export interface StorageContext {
|
||||
includeDependencies?: boolean;
|
||||
includeAreas?: boolean;
|
||||
includeCustomTypes?: boolean;
|
||||
includeNotes?: boolean;
|
||||
}) => Promise<Diagram[]>;
|
||||
getDiagram: (
|
||||
id: string,
|
||||
@@ -39,6 +41,7 @@ export interface StorageContext {
|
||||
includeDependencies?: boolean;
|
||||
includeAreas?: boolean;
|
||||
includeCustomTypes?: boolean;
|
||||
includeNotes?: boolean;
|
||||
}
|
||||
) => Promise<Diagram | undefined>;
|
||||
updateDiagram: (params: {
|
||||
@@ -135,6 +138,20 @@ export interface StorageContext {
|
||||
}) => Promise<void>;
|
||||
listCustomTypes: (diagramId: string) => Promise<DBCustomType[]>;
|
||||
deleteDiagramCustomTypes: (diagramId: string) => Promise<void>;
|
||||
|
||||
// Note operations
|
||||
addNote: (params: { diagramId: string; note: Note }) => Promise<void>;
|
||||
getNote: (params: {
|
||||
diagramId: string;
|
||||
id: string;
|
||||
}) => Promise<Note | undefined>;
|
||||
updateNote: (params: {
|
||||
id: string;
|
||||
attributes: Partial<Note>;
|
||||
}) => Promise<void>;
|
||||
deleteNote: (params: { diagramId: string; id: string }) => Promise<void>;
|
||||
listNotes: (diagramId: string) => Promise<Note[]>;
|
||||
deleteDiagramNotes: (diagramId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const storageInitialValue: StorageContext = {
|
||||
@@ -187,6 +204,14 @@ export const storageInitialValue: StorageContext = {
|
||||
deleteCustomType: emptyFn,
|
||||
listCustomTypes: emptyFn,
|
||||
deleteDiagramCustomTypes: emptyFn,
|
||||
|
||||
// Note operations
|
||||
addNote: emptyFn,
|
||||
getNote: emptyFn,
|
||||
updateNote: emptyFn,
|
||||
deleteNote: emptyFn,
|
||||
listNotes: emptyFn,
|
||||
deleteDiagramNotes: emptyFn,
|
||||
};
|
||||
|
||||
export const storageContext =
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { DBDependency } from '@/lib/domain/db-dependency';
|
||||
import type { Area } from '@/lib/domain/area';
|
||||
import type { DBCustomType } from '@/lib/domain/db-custom-type';
|
||||
import type { DiagramFilter } from '@/lib/domain/diagram-filter/diagram-filter';
|
||||
import type { Note } from '@/lib/domain/note';
|
||||
|
||||
export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
children,
|
||||
@@ -41,6 +42,10 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
DBCustomType & { diagramId: string },
|
||||
'id' // primary key "id" (for the typings only)
|
||||
>;
|
||||
notes: EntityTable<
|
||||
Note & { diagramId: string },
|
||||
'id' // primary key "id" (for the typings only)
|
||||
>;
|
||||
config: EntityTable<
|
||||
ChartDBConfig & { id: number },
|
||||
'id' // primary key "id" (for the typings only)
|
||||
@@ -216,6 +221,23 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
tx.table('config').clear();
|
||||
});
|
||||
|
||||
dexieDB.version(13).stores({
|
||||
diagrams:
|
||||
'++id, name, databaseType, databaseEdition, createdAt, updatedAt',
|
||||
db_tables:
|
||||
'++id, diagramId, name, schema, x, y, fields, indexes, color, createdAt, width, comment, isView, isMaterializedView, order',
|
||||
db_relationships:
|
||||
'++id, diagramId, name, sourceSchema, sourceTableId, targetSchema, targetTableId, sourceFieldId, targetFieldId, type, createdAt',
|
||||
db_dependencies:
|
||||
'++id, diagramId, schema, tableId, dependentSchema, dependentTableId, createdAt',
|
||||
areas: '++id, diagramId, name, x, y, width, height, color',
|
||||
db_custom_types:
|
||||
'++id, diagramId, schema, type, kind, values, fields',
|
||||
config: '++id, defaultDiagramId',
|
||||
diagram_filters: 'diagramId, tableIds, schemasIds',
|
||||
notes: '++id, diagramId, content, x, y, width, height, color',
|
||||
});
|
||||
|
||||
dexieDB.on('ready', async () => {
|
||||
const config = await dexieDB.config.get(1);
|
||||
|
||||
@@ -550,6 +572,56 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
[db]
|
||||
);
|
||||
|
||||
// Note operations
|
||||
const addNote: StorageContext['addNote'] = useCallback(
|
||||
async ({ note, diagramId }) => {
|
||||
await db.notes.add({
|
||||
...note,
|
||||
diagramId,
|
||||
});
|
||||
},
|
||||
[db]
|
||||
);
|
||||
|
||||
const getNote: StorageContext['getNote'] = useCallback(
|
||||
async ({ diagramId, id }) => {
|
||||
return await db.notes.get({ id, diagramId });
|
||||
},
|
||||
[db]
|
||||
);
|
||||
|
||||
const updateNote: StorageContext['updateNote'] = useCallback(
|
||||
async ({ id, attributes }) => {
|
||||
await db.notes.update(id, attributes);
|
||||
},
|
||||
[db]
|
||||
);
|
||||
|
||||
const deleteNote: StorageContext['deleteNote'] = useCallback(
|
||||
async ({ diagramId, id }) => {
|
||||
await db.notes.where({ id, diagramId }).delete();
|
||||
},
|
||||
[db]
|
||||
);
|
||||
|
||||
const listNotes: StorageContext['listNotes'] = useCallback(
|
||||
async (diagramId) => {
|
||||
return await db.notes
|
||||
.where('diagramId')
|
||||
.equals(diagramId)
|
||||
.toArray();
|
||||
},
|
||||
[db]
|
||||
);
|
||||
|
||||
const deleteDiagramNotes: StorageContext['deleteDiagramNotes'] =
|
||||
useCallback(
|
||||
async (diagramId) => {
|
||||
await db.notes.where('diagramId').equals(diagramId).delete();
|
||||
},
|
||||
[db]
|
||||
);
|
||||
|
||||
const addDiagram: StorageContext['addDiagram'] = useCallback(
|
||||
async ({ diagram }) => {
|
||||
const promises = [];
|
||||
@@ -597,9 +669,22 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
)
|
||||
);
|
||||
|
||||
const notes = diagram.notes ?? [];
|
||||
promises.push(
|
||||
...notes.map((note) => addNote({ diagramId: diagram.id, note }))
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
},
|
||||
[db, addArea, addCustomType, addDependency, addRelationship, addTable]
|
||||
[
|
||||
db,
|
||||
addArea,
|
||||
addCustomType,
|
||||
addDependency,
|
||||
addRelationship,
|
||||
addTable,
|
||||
addNote,
|
||||
]
|
||||
);
|
||||
|
||||
const listDiagrams: StorageContext['listDiagrams'] = useCallback(
|
||||
@@ -610,6 +695,7 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
includeDependencies: false,
|
||||
includeAreas: false,
|
||||
includeCustomTypes: false,
|
||||
includeNotes: false,
|
||||
}
|
||||
): Promise<Diagram[]> => {
|
||||
let diagrams = await db.diagrams.toArray();
|
||||
@@ -663,6 +749,15 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (options.includeNotes) {
|
||||
diagrams = await Promise.all(
|
||||
diagrams.map(async (diagram) => {
|
||||
diagram.notes = await listNotes(diagram.id);
|
||||
return diagram;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return diagrams;
|
||||
},
|
||||
[
|
||||
@@ -672,6 +767,7 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
listDependencies,
|
||||
listRelationships,
|
||||
listTables,
|
||||
listNotes,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -684,6 +780,7 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
includeDependencies: false,
|
||||
includeAreas: false,
|
||||
includeCustomTypes: false,
|
||||
includeNotes: false,
|
||||
}
|
||||
): Promise<Diagram | undefined> => {
|
||||
const diagram = await db.diagrams.get(id);
|
||||
@@ -712,6 +809,10 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
diagram.customTypes = await listCustomTypes(id);
|
||||
}
|
||||
|
||||
if (options.includeNotes) {
|
||||
diagram.notes = await listNotes(id);
|
||||
}
|
||||
|
||||
return diagram;
|
||||
},
|
||||
[
|
||||
@@ -721,6 +822,7 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
listDependencies,
|
||||
listRelationships,
|
||||
listTables,
|
||||
listNotes,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -749,6 +851,9 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
.where('diagramId')
|
||||
.equals(id)
|
||||
.modify({ diagramId: attributes.id }),
|
||||
db.notes.where('diagramId').equals(id).modify({
|
||||
diagramId: attributes.id,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
},
|
||||
@@ -764,6 +869,7 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
db.db_dependencies.where('diagramId').equals(id).delete(),
|
||||
db.areas.where('diagramId').equals(id).delete(),
|
||||
db.db_custom_types.where('diagramId').equals(id).delete(),
|
||||
db.notes.where('diagramId').equals(id).delete(),
|
||||
]);
|
||||
},
|
||||
[db]
|
||||
@@ -810,6 +916,12 @@ export const StorageProvider: React.FC<React.PropsWithChildren> = ({
|
||||
deleteCustomType,
|
||||
listCustomTypes,
|
||||
deleteDiagramCustomTypes,
|
||||
addNote,
|
||||
getNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
listNotes,
|
||||
deleteDiagramNotes,
|
||||
getDiagramFilter,
|
||||
updateDiagramFilter,
|
||||
deleteDiagramFilter,
|
||||
|
||||
@@ -42,6 +42,14 @@ import {
|
||||
type ValidationResult,
|
||||
} from '@/lib/data/sql-import/sql-validator';
|
||||
import { SQLValidationStatus } from './sql-validation-status';
|
||||
import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language';
|
||||
import type { ImportMethod } from '@/lib/import-method/import-method';
|
||||
import { detectImportMethod } from '@/lib/import-method/detect-import-method';
|
||||
import { verifyDBML } from '@/lib/dbml/dbml-import/verify-dbml';
|
||||
import {
|
||||
clearErrorHighlight,
|
||||
highlightErrorLine,
|
||||
} from '@/components/code-snippet/dbml/utils';
|
||||
|
||||
const calculateContentSizeMB = (content: string): number => {
|
||||
return content.length / (1024 * 1024); // Convert to MB
|
||||
@@ -55,49 +63,6 @@ const calculateIsLargeFile = (content: string): boolean => {
|
||||
const errorScriptOutputMessage =
|
||||
'Invalid JSON. Please correct it or contact us at support@chartdb.io for help.';
|
||||
|
||||
// Helper to detect if content is likely SQL DDL or JSON
|
||||
const detectContentType = (content: string): 'query' | 'ddl' | null => {
|
||||
if (!content || content.trim().length === 0) return null;
|
||||
|
||||
// Common SQL DDL keywords
|
||||
const ddlKeywords = [
|
||||
'CREATE TABLE',
|
||||
'ALTER TABLE',
|
||||
'DROP TABLE',
|
||||
'CREATE INDEX',
|
||||
'CREATE VIEW',
|
||||
'CREATE PROCEDURE',
|
||||
'CREATE FUNCTION',
|
||||
'CREATE SCHEMA',
|
||||
'CREATE DATABASE',
|
||||
];
|
||||
|
||||
const upperContent = content.toUpperCase();
|
||||
|
||||
// Check for SQL DDL patterns
|
||||
const hasDDLKeywords = ddlKeywords.some((keyword) =>
|
||||
upperContent.includes(keyword)
|
||||
);
|
||||
if (hasDDLKeywords) return 'ddl';
|
||||
|
||||
// Check if it looks like JSON
|
||||
try {
|
||||
// Just check structure, don't need full parse for detection
|
||||
if (
|
||||
(content.trim().startsWith('{') && content.trim().endsWith('}')) ||
|
||||
(content.trim().startsWith('[') && content.trim().endsWith(']'))
|
||||
) {
|
||||
return 'query';
|
||||
}
|
||||
} catch (error) {
|
||||
// Not valid JSON, might be partial
|
||||
console.error('Error detecting content type:', error);
|
||||
}
|
||||
|
||||
// If we can't confidently detect, return null
|
||||
return null;
|
||||
};
|
||||
|
||||
export interface ImportDatabaseProps {
|
||||
goBack?: () => void;
|
||||
onImport: () => void;
|
||||
@@ -111,8 +76,8 @@ export interface ImportDatabaseProps {
|
||||
>;
|
||||
keepDialogAfterImport?: boolean;
|
||||
title: string;
|
||||
importMethod: 'query' | 'ddl';
|
||||
setImportMethod: (method: 'query' | 'ddl') => void;
|
||||
importMethod: ImportMethod;
|
||||
setImportMethod: (method: ImportMethod) => void;
|
||||
}
|
||||
|
||||
export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||
@@ -132,6 +97,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||
const { effectiveTheme } = useTheme();
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
const decorationsCollection = useRef<editor.IEditorDecorationsCollection>();
|
||||
const pasteDisposableRef = useRef<IDisposable | null>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -146,15 +112,20 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||
const [isAutoFixing, setIsAutoFixing] = useState(false);
|
||||
const [showAutoFixButton, setShowAutoFixButton] = useState(false);
|
||||
|
||||
const clearDecorations = useCallback(() => {
|
||||
clearErrorHighlight(decorationsCollection.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setScriptResult('');
|
||||
setErrorMessage('');
|
||||
setShowCheckJsonButton(false);
|
||||
}, [importMethod, setScriptResult]);
|
||||
|
||||
// Check if the ddl is valid
|
||||
// Check if the ddl or dbml is valid
|
||||
useEffect(() => {
|
||||
if (importMethod !== 'ddl') {
|
||||
clearDecorations();
|
||||
if (importMethod === 'query') {
|
||||
setSqlValidation(null);
|
||||
setShowAutoFixButton(false);
|
||||
return;
|
||||
@@ -163,9 +134,54 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||
if (!scriptResult.trim()) {
|
||||
setSqlValidation(null);
|
||||
setShowAutoFixButton(false);
|
||||
setErrorMessage('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (importMethod === 'dbml') {
|
||||
// Validate DBML by parsing it
|
||||
const validateResponse = verifyDBML(scriptResult, { databaseType });
|
||||
if (!validateResponse.hasError) {
|
||||
setErrorMessage('');
|
||||
setSqlValidation({
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
});
|
||||
} else {
|
||||
let errorMsg = 'Invalid DBML syntax';
|
||||
let line: number = 1;
|
||||
|
||||
if (validateResponse.parsedError) {
|
||||
errorMsg = validateResponse.parsedError.message;
|
||||
line = validateResponse.parsedError.line;
|
||||
highlightErrorLine({
|
||||
error: validateResponse.parsedError,
|
||||
model: editorRef.current?.getModel(),
|
||||
editorDecorationsCollection:
|
||||
decorationsCollection.current,
|
||||
});
|
||||
}
|
||||
|
||||
setSqlValidation({
|
||||
isValid: false,
|
||||
errors: [
|
||||
{
|
||||
message: errorMsg,
|
||||
line: line,
|
||||
type: 'syntax' as const,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
setErrorMessage(errorMsg);
|
||||
}
|
||||
|
||||
setShowAutoFixButton(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// SQL validation
|
||||
// First run our validation based on database type
|
||||
const validation = validateSQL(scriptResult, databaseType);
|
||||
setSqlValidation(validation);
|
||||
@@ -192,7 +208,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||
setErrorMessage(result.error);
|
||||
}
|
||||
});
|
||||
}, [importMethod, scriptResult, databaseType]);
|
||||
}, [importMethod, scriptResult, databaseType, clearDecorations]);
|
||||
|
||||
// Check if the script result is a valid JSON
|
||||
useEffect(() => {
|
||||
@@ -320,6 +336,8 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||
const handleEditorDidMount = useCallback(
|
||||
(editor: editor.IStandaloneCodeEditor) => {
|
||||
editorRef.current = editor;
|
||||
decorationsCollection.current =
|
||||
editor.createDecorationsCollection();
|
||||
|
||||
// Cleanup previous disposable if it exists
|
||||
if (pasteDisposableRef.current) {
|
||||
@@ -338,7 +356,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||
const isLargeFile = calculateIsLargeFile(content);
|
||||
|
||||
// First, detect content type to determine if we should switch modes
|
||||
const detectedType = detectContentType(content);
|
||||
const detectedType = detectImportMethod(content);
|
||||
if (detectedType && detectedType !== importMethod) {
|
||||
// Switch to the detected mode immediately
|
||||
setImportMethod(detectedType);
|
||||
@@ -352,7 +370,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||
?.run();
|
||||
}, 100);
|
||||
}
|
||||
// For DDL mode, do NOT format as it can break the SQL
|
||||
// For DDL and DBML modes, do NOT format as it can break the syntax
|
||||
} else {
|
||||
// Content type didn't change, apply formatting based on current mode
|
||||
if (importMethod === 'query' && !isLargeFile) {
|
||||
@@ -363,7 +381,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||
?.run();
|
||||
}, 100);
|
||||
}
|
||||
// For DDL mode or large files, do NOT format
|
||||
// For DDL and DBML modes or large files, do NOT format
|
||||
}
|
||||
});
|
||||
|
||||
@@ -410,16 +428,25 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||
<div className="w-full text-center text-xs text-muted-foreground">
|
||||
{importMethod === 'query'
|
||||
? 'Smart Query Output'
|
||||
: 'SQL Script'}
|
||||
: importMethod === 'dbml'
|
||||
? 'DBML Script'
|
||||
: 'SQL Script'}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Editor
|
||||
value={scriptResult}
|
||||
onChange={debouncedHandleInputChange}
|
||||
language={importMethod === 'query' ? 'json' : 'sql'}
|
||||
language={
|
||||
importMethod === 'query'
|
||||
? 'json'
|
||||
: importMethod === 'dbml'
|
||||
? 'dbml'
|
||||
: 'sql'
|
||||
}
|
||||
loading={<Spinner />}
|
||||
onMount={handleEditorDidMount}
|
||||
beforeMount={setupDBMLLanguage}
|
||||
theme={
|
||||
effectiveTheme === 'dark'
|
||||
? 'dbml-dark'
|
||||
@@ -430,7 +457,6 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
glyphMargin: false,
|
||||
lineNumbers: 'on',
|
||||
guides: {
|
||||
indentation: false,
|
||||
@@ -455,7 +481,9 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{errorMessage || (importMethod === 'ddl' && sqlValidation) ? (
|
||||
{errorMessage ||
|
||||
((importMethod === 'ddl' || importMethod === 'dbml') &&
|
||||
sqlValidation) ? (
|
||||
<SQLValidationStatus
|
||||
validation={sqlValidation}
|
||||
errorMessage={errorMessage}
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
AvatarImage,
|
||||
} from '@/components/avatar/avatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Code } from 'lucide-react';
|
||||
import { Code, FileCode } from 'lucide-react';
|
||||
import { SmartQueryInstructions } from './instructions/smart-query-instructions';
|
||||
import { DDLInstructions } from './instructions/ddl-instructions';
|
||||
import { DBMLInstructions } from './instructions/dbml-instructions';
|
||||
import type { ImportMethod } from '@/lib/import-method/import-method';
|
||||
|
||||
const DatabasesWithoutDDLInstructions: DatabaseType[] = [
|
||||
DatabaseType.CLICKHOUSE,
|
||||
@@ -30,8 +32,8 @@ export interface InstructionsSectionProps {
|
||||
setDatabaseEdition: React.Dispatch<
|
||||
React.SetStateAction<DatabaseEdition | undefined>
|
||||
>;
|
||||
importMethod: 'query' | 'ddl';
|
||||
setImportMethod: (method: 'query' | 'ddl') => void;
|
||||
importMethod: ImportMethod;
|
||||
setImportMethod: (method: ImportMethod) => void;
|
||||
showSSMSInfoDialog: boolean;
|
||||
setShowSSMSInfoDialog: (show: boolean) => void;
|
||||
}
|
||||
@@ -115,48 +117,60 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{DatabasesWithoutDDLInstructions.includes(databaseType) ? null : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm leading-6 text-primary">
|
||||
How would you like to import?
|
||||
</p>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
className="ml-1 flex-wrap justify-start gap-2"
|
||||
value={importMethod}
|
||||
onValueChange={(value) => {
|
||||
let selectedImportMethod: 'query' | 'ddl' = 'query';
|
||||
if (value) {
|
||||
selectedImportMethod = value as 'query' | 'ddl';
|
||||
}
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm leading-6 text-primary">
|
||||
How would you like to import?
|
||||
</p>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
className="ml-1 flex-wrap justify-start gap-2"
|
||||
value={importMethod}
|
||||
onValueChange={(value) => {
|
||||
let selectedImportMethod: ImportMethod = 'query';
|
||||
if (value) {
|
||||
selectedImportMethod = value as ImportMethod;
|
||||
}
|
||||
|
||||
setImportMethod(selectedImportMethod);
|
||||
}}
|
||||
setImportMethod(selectedImportMethod);
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="query"
|
||||
variant="outline"
|
||||
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="query"
|
||||
variant="outline"
|
||||
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
|
||||
>
|
||||
<Avatar className="h-3 w-4 rounded-none">
|
||||
<AvatarImage src={logo} alt="query" />
|
||||
<AvatarFallback>Query</AvatarFallback>
|
||||
</Avatar>
|
||||
Smart Query
|
||||
</ToggleGroupItem>
|
||||
<Avatar className="h-3 w-4 rounded-none">
|
||||
<AvatarImage src={logo} alt="query" />
|
||||
<AvatarFallback>Query</AvatarFallback>
|
||||
</Avatar>
|
||||
Smart Query
|
||||
</ToggleGroupItem>
|
||||
{!DatabasesWithoutDDLInstructions.includes(
|
||||
databaseType
|
||||
) && (
|
||||
<ToggleGroupItem
|
||||
value="ddl"
|
||||
variant="outline"
|
||||
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
|
||||
>
|
||||
<Avatar className="size-4 rounded-none">
|
||||
<Code size={16} />
|
||||
<FileCode size={16} />
|
||||
</Avatar>
|
||||
SQL Script
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<ToggleGroupItem
|
||||
value="dbml"
|
||||
variant="outline"
|
||||
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
|
||||
>
|
||||
<Avatar className="size-4 rounded-none">
|
||||
<Code size={16} />
|
||||
</Avatar>
|
||||
DBML
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm font-semibold">Instructions:</div>
|
||||
@@ -167,11 +181,16 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
|
||||
showSSMSInfoDialog={showSSMSInfoDialog}
|
||||
setShowSSMSInfoDialog={setShowSSMSInfoDialog}
|
||||
/>
|
||||
) : (
|
||||
) : importMethod === 'ddl' ? (
|
||||
<DDLInstructions
|
||||
databaseType={databaseType}
|
||||
databaseEdition={databaseEdition}
|
||||
/>
|
||||
) : (
|
||||
<DBMLInstructions
|
||||
databaseType={databaseType}
|
||||
databaseEdition={databaseEdition}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import type { DatabaseType } from '@/lib/domain/database-type';
|
||||
import type { DatabaseEdition } from '@/lib/domain/database-edition';
|
||||
import { CodeSnippet } from '@/components/code-snippet/code-snippet';
|
||||
import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language';
|
||||
|
||||
export interface DBMLInstructionsProps {
|
||||
databaseType: DatabaseType;
|
||||
databaseEdition?: DatabaseEdition;
|
||||
}
|
||||
|
||||
export const DBMLInstructions: React.FC<DBMLInstructionsProps> = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-1 text-sm text-primary">
|
||||
<div>
|
||||
Paste your DBML (Database Markup Language) schema definition
|
||||
here →
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-64 flex-col gap-1 text-sm text-primary">
|
||||
<h4 className="text-xs font-medium">Example:</h4>
|
||||
<CodeSnippet
|
||||
className="h-full"
|
||||
allowCopy={false}
|
||||
editorProps={{
|
||||
beforeMount: setupDBMLLanguage,
|
||||
}}
|
||||
code={`Table users {
|
||||
id int [pk]
|
||||
username varchar
|
||||
email varchar
|
||||
}
|
||||
|
||||
Table posts {
|
||||
id int [pk]
|
||||
user_id int [ref: > users.id]
|
||||
title varchar
|
||||
content text
|
||||
}`}
|
||||
language={'dbml'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -43,8 +43,8 @@ const DDLInstructionsMap: Record<DatabaseType, DDLInstruction[]> = {
|
||||
},
|
||||
{
|
||||
text: 'Execute the following command in your terminal:',
|
||||
code: `sqlite3 <database_file_path>\n.dump > <output_file_path>`,
|
||||
example: `sqlite3 my_db.db\n.dump > schema_export.sql`,
|
||||
code: `sqlite3 <database_file_path>\n".schema" > <output_file_path>`,
|
||||
example: `sqlite3 my_db.db\n".schema" > schema_export.sql`,
|
||||
},
|
||||
{
|
||||
text: 'Open the exported SQL file, copy its contents, and paste them here.',
|
||||
|
||||
@@ -57,36 +57,11 @@ export const SQLValidationStatus: React.FC<SQLValidationStatusProps> = ({
|
||||
|
||||
// If we have parser errors (errorMessage) after validation
|
||||
if (errorMessage && !hasErrors) {
|
||||
// Check if the error is related to parsing issues
|
||||
const isParsingError =
|
||||
errorMessage.toLowerCase().includes('error parsing') ||
|
||||
errorMessage.toLowerCase().includes('unexpected');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator className="mb-1 mt-2" />
|
||||
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950">
|
||||
<div className="space-y-3 p-3 pt-2 text-red-700 dark:text-red-300">
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageCircleWarning className="mt-0.5 size-4 shrink-0 text-red-700 dark:text-red-300" />
|
||||
<div className="flex-1 text-sm text-red-700 dark:text-red-300">
|
||||
<div className="font-medium">
|
||||
{isParsingError
|
||||
? 'SQL Parsing Failed'
|
||||
: 'SQL Import Error'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs">
|
||||
{errorMessage}
|
||||
</div>
|
||||
{isParsingError && (
|
||||
<div className="mt-2 text-xs opacity-90">
|
||||
This may indicate incompatible SQL
|
||||
syntax for the selected database type.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-1 flex shrink-0 items-center gap-2">
|
||||
<p className="text-xs text-red-700">{errorMessage}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -98,7 +73,7 @@ export const SQLValidationStatus: React.FC<SQLValidationStatusProps> = ({
|
||||
|
||||
{hasErrors ? (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950">
|
||||
<ScrollArea className="h-24">
|
||||
<ScrollArea className="h-fit max-h-24">
|
||||
<div className="space-y-3 p-3 pt-2 text-red-700 dark:text-red-300">
|
||||
{validation?.errors
|
||||
.slice(0, 3)
|
||||
@@ -162,7 +137,7 @@ export const SQLValidationStatus: React.FC<SQLValidationStatusProps> = ({
|
||||
|
||||
{hasWarnings && !hasErrors ? (
|
||||
<div className="rounded-md border border-sky-200 bg-sky-50 dark:border-sky-800 dark:bg-sky-950">
|
||||
<ScrollArea className="h-24">
|
||||
<ScrollArea className="h-fit max-h-24">
|
||||
<div className="space-y-3 p-3 pt-2 text-sky-700 dark:text-sky-300">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-sky-700 dark:text-sky-300" />
|
||||
|
||||
@@ -22,6 +22,11 @@ import { sqlImportToDiagram } from '@/lib/data/sql-import';
|
||||
import type { SelectedTable } from '@/lib/data/import-metadata/filter-metadata';
|
||||
import { filterMetadataByTables } from '@/lib/data/import-metadata/filter-metadata';
|
||||
import { MAX_TABLES_WITHOUT_SHOWING_FILTER } from '../common/select-tables/constants';
|
||||
import {
|
||||
defaultDBMLDiagramName,
|
||||
importDBMLToDiagram,
|
||||
} from '@/lib/dbml/dbml-import/dbml-import';
|
||||
import type { ImportMethod } from '@/lib/import-method/import-method';
|
||||
|
||||
export interface CreateDiagramDialogProps extends BaseDialogProps {}
|
||||
|
||||
@@ -30,11 +35,11 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
|
||||
}) => {
|
||||
const { diagramId } = useChartDB();
|
||||
const { t } = useTranslation();
|
||||
const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query');
|
||||
const [importMethod, setImportMethod] = useState<ImportMethod>('query');
|
||||
const [databaseType, setDatabaseType] = useState<DatabaseType>(
|
||||
DatabaseType.GENERIC
|
||||
);
|
||||
const { closeCreateDiagramDialog, openImportDBMLDialog } = useDialog();
|
||||
const { closeCreateDiagramDialog } = useDialog();
|
||||
const { updateConfig } = useConfig();
|
||||
const [scriptResult, setScriptResult] = useState('');
|
||||
const [databaseEdition, setDatabaseEdition] = useState<
|
||||
@@ -89,6 +94,14 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
|
||||
sourceDatabaseType: databaseType,
|
||||
targetDatabaseType: databaseType,
|
||||
});
|
||||
} else if (importMethod === 'dbml') {
|
||||
diagram = await importDBMLToDiagram(scriptResult, {
|
||||
databaseType,
|
||||
});
|
||||
// Update the diagram name if it's the default
|
||||
if (diagram.name === defaultDBMLDiagramName) {
|
||||
diagram.name = `Diagram ${diagramNumber}`;
|
||||
}
|
||||
} else {
|
||||
let metadata: DatabaseMetadata | undefined = databaseMetadata;
|
||||
|
||||
@@ -152,10 +165,6 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
|
||||
await updateConfig({ config: { defaultDiagramId: diagram.id } });
|
||||
closeCreateDiagramDialog();
|
||||
navigate(`/diagrams/${diagram.id}`);
|
||||
setTimeout(
|
||||
() => openImportDBMLDialog({ withCreateEmptyDiagram: true }),
|
||||
700
|
||||
);
|
||||
}, [
|
||||
databaseType,
|
||||
addDiagram,
|
||||
@@ -164,14 +173,13 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
|
||||
navigate,
|
||||
updateConfig,
|
||||
diagramNumber,
|
||||
openImportDBMLDialog,
|
||||
]);
|
||||
|
||||
const importNewDiagramOrFilterTables = useCallback(async () => {
|
||||
try {
|
||||
setIsParsingMetadata(true);
|
||||
|
||||
if (importMethod === 'ddl') {
|
||||
if (importMethod === 'ddl' || importMethod === 'dbml') {
|
||||
await importNewDiagram();
|
||||
} else {
|
||||
// Parse metadata asynchronously to avoid blocking the UI
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { ToggleGroup } from '@/components/toggle/toggle-group';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
import { DatabaseOption } from './database-option';
|
||||
import { ExampleOption } from './example-option';
|
||||
import { Button } from '@/components/button/button';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/tabs/tabs';
|
||||
export interface SelectDatabaseContentProps {
|
||||
databaseType: DatabaseType;
|
||||
setDatabaseType: React.Dispatch<React.SetStateAction<DatabaseType>>;
|
||||
onContinue: () => void;
|
||||
onContinue: (selectedDatabaseType: DatabaseType) => void;
|
||||
}
|
||||
|
||||
const ROW_SIZE = 3;
|
||||
const ROWS = 2;
|
||||
const TOTAL_SLOTS = ROW_SIZE * ROWS;
|
||||
const SUPPORTED_DB_TYPES: DatabaseType[] = [
|
||||
|
||||
// Transactional databases - OLTP systems optimized for frequent read/write operations
|
||||
const TRANSACTIONAL_DB_TYPES: DatabaseType[] = [
|
||||
DatabaseType.MYSQL,
|
||||
DatabaseType.POSTGRESQL,
|
||||
DatabaseType.MARIADB,
|
||||
@@ -22,69 +30,87 @@ const SUPPORTED_DB_TYPES: DatabaseType[] = [
|
||||
DatabaseType.SQL_SERVER,
|
||||
DatabaseType.ORACLE,
|
||||
DatabaseType.COCKROACHDB,
|
||||
DatabaseType.CLICKHOUSE,
|
||||
];
|
||||
|
||||
// Analytical databases - OLAP systems optimized for complex queries and analytics
|
||||
const ANALYTICAL_DB_TYPES: DatabaseType[] = [DatabaseType.CLICKHOUSE];
|
||||
|
||||
export const SelectDatabaseContent: React.FC<SelectDatabaseContentProps> = ({
|
||||
databaseType,
|
||||
setDatabaseType,
|
||||
onContinue,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'transactional' | 'analytical'>(
|
||||
'transactional'
|
||||
);
|
||||
const [currentRow, setCurrentRow] = useState(0);
|
||||
|
||||
const currentDbTypes =
|
||||
activeTab === 'transactional'
|
||||
? TRANSACTIONAL_DB_TYPES
|
||||
: ANALYTICAL_DB_TYPES;
|
||||
|
||||
const currentDatabasesTypes = useMemo(
|
||||
() =>
|
||||
SUPPORTED_DB_TYPES.slice(
|
||||
currentDbTypes.slice(
|
||||
currentRow * ROW_SIZE,
|
||||
currentRow * ROW_SIZE + TOTAL_SLOTS
|
||||
),
|
||||
[currentRow]
|
||||
[currentRow, currentDbTypes]
|
||||
);
|
||||
|
||||
const hasNextRow = useMemo(
|
||||
() => (currentRow + 1) * ROW_SIZE < SUPPORTED_DB_TYPES.length,
|
||||
[currentRow]
|
||||
() => (currentRow + 1) * ROW_SIZE < currentDbTypes.length,
|
||||
[currentRow, currentDbTypes]
|
||||
);
|
||||
|
||||
const hasPreviousRow = useMemo(() => currentRow > 0, [currentRow]);
|
||||
|
||||
const toggleRow = () => {
|
||||
const toggleRow = useCallback(() => {
|
||||
if (currentRow === 0 && hasNextRow) {
|
||||
setCurrentRow(currentRow + 1);
|
||||
} else if (currentRow > 0) {
|
||||
setCurrentRow(currentRow - 1);
|
||||
}
|
||||
};
|
||||
}, [currentRow, hasNextRow]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4">
|
||||
<ToggleGroup
|
||||
value={databaseType}
|
||||
onValueChange={(value: DatabaseType) => {
|
||||
if (!value) {
|
||||
setDatabaseType(DatabaseType.GENERIC);
|
||||
} else {
|
||||
setDatabaseType(value);
|
||||
onContinue();
|
||||
}
|
||||
}}
|
||||
type="single"
|
||||
className="grid grid-flow-row grid-cols-3 gap-6"
|
||||
>
|
||||
{Array.from({ length: TOTAL_SLOTS }).map((_, index) =>
|
||||
currentDatabasesTypes?.[index] ? (
|
||||
<DatabaseOption
|
||||
key={currentDatabasesTypes[index]}
|
||||
type={currentDatabasesTypes[index]}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value as 'transactional' | 'analytical');
|
||||
setCurrentRow(0); // Reset to first row when switching tabs
|
||||
}, []);
|
||||
|
||||
<div className="col-span-3 flex flex-1 flex-col gap-1">
|
||||
const renderDatabaseGrid = useCallback(
|
||||
() => (
|
||||
<div className="flex min-h-[280px] flex-col md:min-h-[370px]">
|
||||
<ToggleGroup
|
||||
value={databaseType}
|
||||
onValueChange={(value: DatabaseType) => {
|
||||
if (!value) {
|
||||
setDatabaseType(DatabaseType.GENERIC);
|
||||
} else {
|
||||
setDatabaseType(value);
|
||||
onContinue(value);
|
||||
}
|
||||
}}
|
||||
type="single"
|
||||
className="grid grid-flow-row grid-cols-3 content-start gap-4"
|
||||
>
|
||||
{Array.from({ length: TOTAL_SLOTS }).map((_, index) =>
|
||||
currentDatabasesTypes?.[index] ? (
|
||||
<DatabaseOption
|
||||
key={currentDatabasesTypes[index]}
|
||||
type={currentDatabasesTypes[index]}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</ToggleGroup>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-1 pt-4">
|
||||
{hasNextRow || hasPreviousRow ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggleRow}
|
||||
className="col-span-3 h-8"
|
||||
className="h-8"
|
||||
>
|
||||
{currentRow === 0 ? (
|
||||
<div className="flex h-8 w-full cursor-pointer flex-row items-center justify-center gap-2 py-3 text-center md:h-12">
|
||||
@@ -105,7 +131,55 @@ export const SelectDatabaseContent: React.FC<SelectDatabaseContentProps> = ({
|
||||
) : null}
|
||||
<ExampleOption />
|
||||
</div>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
),
|
||||
[
|
||||
databaseType,
|
||||
currentDatabasesTypes,
|
||||
hasNextRow,
|
||||
hasPreviousRow,
|
||||
onContinue,
|
||||
setDatabaseType,
|
||||
toggleRow,
|
||||
currentRow,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center gap-2">
|
||||
<Tabs
|
||||
defaultValue="transactional"
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="w-auto"
|
||||
>
|
||||
<TabsList className="mb-2 grid size-auto grid-cols-2 gap-1 rounded-xl border bg-background p-1">
|
||||
<TabsTrigger
|
||||
value="transactional"
|
||||
className="gap-1.5 rounded-lg px-3 py-1 text-sm font-medium transition-all data-[state=active]:bg-sky-600 data-[state=active]:text-white data-[state=inactive]:text-muted-foreground data-[state=active]:shadow-sm data-[state=inactive]:hover:bg-muted/50 data-[state=inactive]:hover:text-foreground dark:data-[state=active]:bg-sky-500"
|
||||
>
|
||||
Transactional
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="analytical"
|
||||
className="gap-1.5 rounded-lg px-3 py-1 text-sm font-medium transition-all data-[state=active]:bg-sky-600 data-[state=active]:text-white data-[state=inactive]:text-muted-foreground data-[state=active]:shadow-sm data-[state=inactive]:hover:bg-muted/50 data-[state=inactive]:hover:text-foreground dark:data-[state=active]:bg-sky-500"
|
||||
>
|
||||
Analytical
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="transactional"
|
||||
className="mt-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
{renderDatabaseGrid()}
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="analytical"
|
||||
className="mt-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
{renderDatabaseGrid()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useReactFlow } from '@xyflow/react';
|
||||
import type { BaseDialogProps } from '../common/base-dialog-props';
|
||||
import { useAlert } from '@/context/alert-context/alert-context';
|
||||
import { sqlImportToDiagram } from '@/lib/data/sql-import';
|
||||
import { importDBMLToDiagram } from '@/lib/dbml/dbml-import/dbml-import';
|
||||
import type { ImportMethod } from '@/lib/import-method/import-method';
|
||||
|
||||
export interface ImportDatabaseDialogProps extends BaseDialogProps {
|
||||
databaseType: DatabaseType;
|
||||
@@ -24,7 +26,7 @@ export const ImportDatabaseDialog: React.FC<ImportDatabaseDialogProps> = ({
|
||||
dialog,
|
||||
databaseType,
|
||||
}) => {
|
||||
const [importMethod, setImportMethod] = useState<'query' | 'ddl'>('query');
|
||||
const [importMethod, setImportMethod] = useState<ImportMethod>('query');
|
||||
const { closeImportDatabaseDialog } = useDialog();
|
||||
const { showAlert } = useAlert();
|
||||
const {
|
||||
@@ -65,6 +67,10 @@ export const ImportDatabaseDialog: React.FC<ImportDatabaseDialogProps> = ({
|
||||
sourceDatabaseType: databaseType,
|
||||
targetDatabaseType: databaseType,
|
||||
});
|
||||
} else if (importMethod === 'dbml') {
|
||||
diagram = await importDBMLToDiagram(scriptResult, {
|
||||
databaseType,
|
||||
});
|
||||
} else {
|
||||
const databaseMetadata: DatabaseMetadata =
|
||||
loadDatabaseMetadata(scriptResult);
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
Suspense,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import type * as monaco from 'monaco-editor';
|
||||
import { useDialog } from '@/hooks/use-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogInternalContent,
|
||||
DialogTitle,
|
||||
} from '@/components/dialog/dialog';
|
||||
import { Button } from '@/components/button/button';
|
||||
import type { BaseDialogProps } from '../common/base-dialog-props';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Editor } from '@/components/code-snippet/code-snippet';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
importDBMLToDiagram,
|
||||
sanitizeDBML,
|
||||
preprocessDBML,
|
||||
} from '@/lib/dbml/dbml-import/dbml-import';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { Parser } from '@dbml/core';
|
||||
import { useCanvas } from '@/hooks/use-canvas';
|
||||
import { setupDBMLLanguage } from '@/components/code-snippet/languages/dbml-language';
|
||||
import type { DBTable } from '@/lib/domain/db-table';
|
||||
import { useToast } from '@/components/toast/use-toast';
|
||||
import { Spinner } from '@/components/spinner/spinner';
|
||||
import { debounce } from '@/lib/utils';
|
||||
import { parseDBMLError } from '@/lib/dbml/dbml-import/dbml-import-error';
|
||||
import {
|
||||
clearErrorHighlight,
|
||||
highlightErrorLine,
|
||||
} from '@/components/code-snippet/dbml/utils';
|
||||
|
||||
export interface ImportDBMLDialogProps extends BaseDialogProps {
|
||||
withCreateEmptyDiagram?: boolean;
|
||||
}
|
||||
|
||||
export const ImportDBMLDialog: React.FC<ImportDBMLDialogProps> = ({
|
||||
dialog,
|
||||
withCreateEmptyDiagram,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const initialDBML = `// Use DBML to define your database structure
|
||||
// Simple Blog System with Comments Example
|
||||
|
||||
Table users {
|
||||
id integer [primary key]
|
||||
name varchar
|
||||
email varchar
|
||||
}
|
||||
|
||||
Table posts {
|
||||
id integer [primary key]
|
||||
title varchar
|
||||
content text
|
||||
user_id integer
|
||||
created_at timestamp
|
||||
}
|
||||
|
||||
Table comments {
|
||||
id integer [primary key]
|
||||
content text
|
||||
post_id integer
|
||||
user_id integer
|
||||
created_at timestamp
|
||||
}
|
||||
|
||||
// Relationships
|
||||
Ref: posts.user_id > users.id // Each post belongs to one user
|
||||
Ref: comments.post_id > posts.id // Each comment belongs to one post
|
||||
Ref: comments.user_id > users.id // Each comment is written by one user`;
|
||||
|
||||
const [dbmlContent, setDBMLContent] = useState<string>(initialDBML);
|
||||
const { closeImportDBMLDialog } = useDialog();
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>();
|
||||
const { effectiveTheme } = useTheme();
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
addTables,
|
||||
addRelationships,
|
||||
tables,
|
||||
relationships,
|
||||
removeTables,
|
||||
removeRelationships,
|
||||
} = useChartDB();
|
||||
const { reorderTables } = useCanvas();
|
||||
const [reorder, setReorder] = useState(false);
|
||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();
|
||||
const decorationsCollection =
|
||||
useRef<monaco.editor.IEditorDecorationsCollection>();
|
||||
|
||||
const handleEditorDidMount = (
|
||||
editor: monaco.editor.IStandaloneCodeEditor
|
||||
) => {
|
||||
editorRef.current = editor;
|
||||
decorationsCollection.current = editor.createDecorationsCollection();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (reorder) {
|
||||
reorderTables({
|
||||
updateHistory: false,
|
||||
});
|
||||
setReorder(false);
|
||||
}
|
||||
}, [reorder, reorderTables]);
|
||||
|
||||
const clearDecorations = useCallback(() => {
|
||||
clearErrorHighlight(decorationsCollection.current);
|
||||
}, []);
|
||||
|
||||
const validateDBML = useCallback(
|
||||
async (content: string) => {
|
||||
// Clear previous errors
|
||||
setErrorMessage(undefined);
|
||||
clearDecorations();
|
||||
|
||||
if (!content.trim()) return;
|
||||
|
||||
try {
|
||||
const preprocessedContent = preprocessDBML(content);
|
||||
const sanitizedContent = sanitizeDBML(preprocessedContent);
|
||||
const parser = new Parser();
|
||||
parser.parse(sanitizedContent, 'dbmlv2');
|
||||
} catch (e) {
|
||||
const parsedError = parseDBMLError(e);
|
||||
if (parsedError) {
|
||||
setErrorMessage(
|
||||
t('import_dbml_dialog.error.description') +
|
||||
` (1 error found - in line ${parsedError.line})`
|
||||
);
|
||||
highlightErrorLine({
|
||||
error: parsedError,
|
||||
model: editorRef.current?.getModel(),
|
||||
editorDecorationsCollection:
|
||||
decorationsCollection.current,
|
||||
});
|
||||
} else {
|
||||
setErrorMessage(
|
||||
e instanceof Error ? e.message : JSON.stringify(e)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[clearDecorations, t]
|
||||
);
|
||||
|
||||
const debouncedValidateRef = useRef<((value: string) => void) | null>(null);
|
||||
|
||||
// Set up debounced validation
|
||||
useEffect(() => {
|
||||
debouncedValidateRef.current = debounce((value: string) => {
|
||||
validateDBML(value);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
debouncedValidateRef.current = null;
|
||||
};
|
||||
}, [validateDBML]);
|
||||
|
||||
// Trigger validation when content changes
|
||||
useEffect(() => {
|
||||
if (debouncedValidateRef.current) {
|
||||
debouncedValidateRef.current(dbmlContent);
|
||||
}
|
||||
}, [dbmlContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialog.open) {
|
||||
setErrorMessage(undefined);
|
||||
clearDecorations();
|
||||
setDBMLContent(initialDBML);
|
||||
}
|
||||
}, [dialog.open, initialDBML, clearDecorations]);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!dbmlContent.trim() || errorMessage) return;
|
||||
|
||||
try {
|
||||
const importedDiagram = await importDBMLToDiagram(dbmlContent);
|
||||
const tableIdsToRemove = tables
|
||||
.filter((table) =>
|
||||
importedDiagram.tables?.some(
|
||||
(t: DBTable) =>
|
||||
t.name === table.name && t.schema === table.schema
|
||||
)
|
||||
)
|
||||
.map((table) => table.id);
|
||||
// Find relationships that need to be removed
|
||||
const relationshipIdsToRemove = relationships
|
||||
.filter((relationship) => {
|
||||
const sourceTable = tables.find(
|
||||
(table: DBTable) =>
|
||||
table.id === relationship.sourceTableId
|
||||
);
|
||||
const targetTable = tables.find(
|
||||
(table: DBTable) =>
|
||||
table.id === relationship.targetTableId
|
||||
);
|
||||
if (!sourceTable || !targetTable) return true;
|
||||
const replacementSourceTable = importedDiagram.tables?.find(
|
||||
(table: DBTable) =>
|
||||
table.name === sourceTable.name &&
|
||||
table.schema === sourceTable.schema
|
||||
);
|
||||
const replacementTargetTable = importedDiagram.tables?.find(
|
||||
(table: DBTable) =>
|
||||
table.name === targetTable.name &&
|
||||
table.schema === targetTable.schema
|
||||
);
|
||||
return replacementSourceTable || replacementTargetTable;
|
||||
})
|
||||
.map((relationship) => relationship.id);
|
||||
|
||||
// Remove existing items
|
||||
await Promise.all([
|
||||
removeTables(tableIdsToRemove, { updateHistory: false }),
|
||||
removeRelationships(relationshipIdsToRemove, {
|
||||
updateHistory: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Add new items
|
||||
await Promise.all([
|
||||
addTables(importedDiagram.tables ?? [], {
|
||||
updateHistory: false,
|
||||
}),
|
||||
addRelationships(importedDiagram.relationships ?? [], {
|
||||
updateHistory: false,
|
||||
}),
|
||||
]);
|
||||
setReorder(true);
|
||||
closeImportDBMLDialog();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t('import_dbml_dialog.error.title'),
|
||||
variant: 'destructive',
|
||||
description: (
|
||||
<>
|
||||
<div>{t('import_dbml_dialog.error.description')}</div>
|
||||
{e instanceof Error ? e.message : JSON.stringify(e)}
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [
|
||||
dbmlContent,
|
||||
closeImportDBMLDialog,
|
||||
tables,
|
||||
relationships,
|
||||
removeTables,
|
||||
removeRelationships,
|
||||
addTables,
|
||||
addRelationships,
|
||||
errorMessage,
|
||||
toast,
|
||||
setReorder,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...dialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeImportDBMLDialog();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="flex h-[80vh] max-h-screen w-full flex-col md:max-w-[900px]"
|
||||
showClose
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{withCreateEmptyDiagram
|
||||
? t('import_dbml_dialog.example_title')
|
||||
: t('import_dbml_dialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('import_dbml_dialog.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogInternalContent>
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Editor
|
||||
value={dbmlContent}
|
||||
onChange={(value) => setDBMLContent(value || '')}
|
||||
language="dbml"
|
||||
onMount={handleEditorDidMount}
|
||||
theme={
|
||||
effectiveTheme === 'dark'
|
||||
? 'dbml-dark'
|
||||
: 'dbml-light'
|
||||
}
|
||||
beforeMount={setupDBMLLanguage}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
glyphMargin: true,
|
||||
lineNumbers: 'on',
|
||||
scrollbar: {
|
||||
vertical: 'visible',
|
||||
horizontal: 'visible',
|
||||
},
|
||||
}}
|
||||
className="size-full"
|
||||
/>
|
||||
</Suspense>
|
||||
</DialogInternalContent>
|
||||
<DialogFooter>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">
|
||||
{withCreateEmptyDiagram
|
||||
? t('import_dbml_dialog.skip_and_empty')
|
||||
: t('import_dbml_dialog.cancel')}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
{errorMessage ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertCircle className="size-4 text-destructive" />
|
||||
|
||||
<span className="text-xs text-destructive">
|
||||
{errorMessage ||
|
||||
t(
|
||||
'import_dbml_dialog.error.description'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!dbmlContent.trim() || !!errorMessage}
|
||||
>
|
||||
{withCreateEmptyDiagram
|
||||
? t('import_dbml_dialog.show_example')
|
||||
: t('import_dbml_dialog.import')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -88,6 +88,44 @@ export const useFocusOn = () => {
|
||||
[fitView, setNodes, hideSidePanel, isDesktop]
|
||||
);
|
||||
|
||||
const focusOnNote = useCallback(
|
||||
(noteId: string, options: FocusOptions = {}) => {
|
||||
const { select = true } = options;
|
||||
|
||||
if (select) {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === noteId
|
||||
? {
|
||||
...node,
|
||||
selected: true,
|
||||
}
|
||||
: {
|
||||
...node,
|
||||
selected: false,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fitView({
|
||||
duration: 500,
|
||||
maxZoom: 1,
|
||||
minZoom: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: noteId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!isDesktop) {
|
||||
hideSidePanel();
|
||||
}
|
||||
},
|
||||
[fitView, setNodes, hideSidePanel, isDesktop]
|
||||
);
|
||||
|
||||
const focusOnRelationship = useCallback(
|
||||
(
|
||||
relationshipId: string,
|
||||
@@ -137,6 +175,7 @@ export const useFocusOn = () => {
|
||||
return {
|
||||
focusOnArea,
|
||||
focusOnTable,
|
||||
focusOnNote,
|
||||
focusOnRelationship,
|
||||
};
|
||||
};
|
||||
|
||||
379
src/hooks/use-update-table-field.ts
Normal file
379
src/hooks/use-update-table-field.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
|
||||
import { useChartDB } from './use-chartdb';
|
||||
import { useDebounce } from './use-debounce-v2';
|
||||
import type { DatabaseType, DBField, DBTable } from '@/lib/domain';
|
||||
import type {
|
||||
SelectBoxOption,
|
||||
SelectBoxProps,
|
||||
} from '@/components/select-box/select-box';
|
||||
import {
|
||||
dataTypeDataToDataType,
|
||||
sortedDataTypeMap,
|
||||
supportsArrayDataType,
|
||||
autoIncrementAlwaysOn,
|
||||
requiresNotNull,
|
||||
} from '@/lib/data/data-types/data-types';
|
||||
import { generateDBFieldSuffix } from '@/lib/domain/db-field';
|
||||
import type { DataTypeData } from '@/lib/data/data-types/data-types';
|
||||
|
||||
const generateFieldRegexPatterns = (
|
||||
dataType: DataTypeData,
|
||||
databaseType: DatabaseType
|
||||
): {
|
||||
regex?: string;
|
||||
extractRegex?: RegExp;
|
||||
} => {
|
||||
const typeName = dataType.name;
|
||||
const supportsArrays = supportsArrayDataType(dataType.id, databaseType);
|
||||
const arrayPattern = supportsArrays ? '(\\[\\])?' : '';
|
||||
|
||||
if (!dataType.fieldAttributes) {
|
||||
// For types without field attributes, support plain type + optional array notation
|
||||
return {
|
||||
regex: `^${typeName}${arrayPattern}$`,
|
||||
extractRegex: new RegExp(`^${typeName}${arrayPattern}$`),
|
||||
};
|
||||
}
|
||||
|
||||
const fieldAttributes = dataType.fieldAttributes;
|
||||
|
||||
if (fieldAttributes.hasCharMaxLength) {
|
||||
if (fieldAttributes.hasCharMaxLengthOption) {
|
||||
return {
|
||||
regex: `^${typeName}\\((\\d+|[mM][aA][xX])\\)${arrayPattern}$`,
|
||||
extractRegex: supportsArrays
|
||||
? /\((\d+|max)\)(\[\])?/i
|
||||
: /\((\d+|max)\)/i,
|
||||
};
|
||||
}
|
||||
return {
|
||||
regex: `^${typeName}\\(\\d+\\)${arrayPattern}$`,
|
||||
extractRegex: supportsArrays ? /\((\d+)\)(\[\])?/ : /\((\d+)\)/,
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldAttributes.precision && fieldAttributes.scale) {
|
||||
return {
|
||||
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)${arrayPattern}$`,
|
||||
extractRegex: new RegExp(
|
||||
`${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)${arrayPattern}`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldAttributes.precision) {
|
||||
return {
|
||||
regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)${arrayPattern}$`,
|
||||
extractRegex: supportsArrays ? /\((\d+)\)(\[\])?/ : /\((\d+)\)/,
|
||||
};
|
||||
}
|
||||
|
||||
return { regex: undefined, extractRegex: undefined };
|
||||
};
|
||||
|
||||
export const useUpdateTableField = (
|
||||
table: DBTable,
|
||||
field: DBField,
|
||||
customUpdateField?: (attrs: Partial<DBField>) => void
|
||||
) => {
|
||||
const {
|
||||
databaseType,
|
||||
customTypes,
|
||||
updateField: chartDBUpdateField,
|
||||
removeField: chartDBRemoveField,
|
||||
} = useChartDB();
|
||||
|
||||
// Local state for responsive UI
|
||||
const [localFieldName, setLocalFieldName] = useState(field.name);
|
||||
const [localNullable, setLocalNullable] = useState(field.nullable);
|
||||
const [localPrimaryKey, setLocalPrimaryKey] = useState(field.primaryKey);
|
||||
|
||||
const lastFieldNameRef = useRef<string>(field.name);
|
||||
|
||||
useEffect(() => {
|
||||
if (localFieldName === lastFieldNameRef.current) {
|
||||
lastFieldNameRef.current = field.name;
|
||||
setLocalFieldName(field.name);
|
||||
}
|
||||
}, [field.name, localFieldName]);
|
||||
|
||||
// Update local state when field properties change externally
|
||||
useEffect(() => {
|
||||
setLocalNullable(field.nullable);
|
||||
setLocalPrimaryKey(field.primaryKey);
|
||||
}, [field.nullable, field.primaryKey]);
|
||||
|
||||
// Use custom updateField if provided, otherwise use the chartDB one
|
||||
const updateField = useMemo(
|
||||
() =>
|
||||
customUpdateField
|
||||
? (
|
||||
_tableId: string,
|
||||
_fieldId: string,
|
||||
attrs: Partial<DBField>
|
||||
) => customUpdateField(attrs)
|
||||
: chartDBUpdateField,
|
||||
[customUpdateField, chartDBUpdateField]
|
||||
);
|
||||
|
||||
// Calculate primary key fields for validation
|
||||
const primaryKeyFields = useMemo(() => {
|
||||
return table.fields.filter((f) => f.primaryKey);
|
||||
}, [table.fields]);
|
||||
|
||||
const primaryKeyCount = useMemo(
|
||||
() => primaryKeyFields.length,
|
||||
[primaryKeyFields.length]
|
||||
);
|
||||
|
||||
// Generate data type options for select box
|
||||
const dataFieldOptions = useMemo(() => {
|
||||
const standardTypes: SelectBoxOption[] = sortedDataTypeMap[
|
||||
databaseType
|
||||
].map((type) => {
|
||||
const regexPatterns = generateFieldRegexPatterns(
|
||||
type,
|
||||
databaseType
|
||||
);
|
||||
|
||||
return {
|
||||
label: type.name,
|
||||
value: type.id,
|
||||
regex: regexPatterns.regex,
|
||||
extractRegex: regexPatterns.extractRegex,
|
||||
group: customTypes?.length ? 'Standard Types' : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
if (!customTypes?.length) {
|
||||
return standardTypes;
|
||||
}
|
||||
|
||||
// Add custom types as options
|
||||
const customTypeOptions: SelectBoxOption[] = customTypes.map(
|
||||
(type) => ({
|
||||
label: type.name,
|
||||
value: type.name,
|
||||
description:
|
||||
type.kind === 'enum' ? `${type.values?.join(' | ')}` : '',
|
||||
group: 'Custom Types',
|
||||
})
|
||||
);
|
||||
|
||||
return [...standardTypes, ...customTypeOptions];
|
||||
}, [databaseType, customTypes]);
|
||||
|
||||
// Handle data type change
|
||||
const handleDataTypeChange = useCallback<
|
||||
NonNullable<SelectBoxProps['onChange']>
|
||||
>(
|
||||
(value, regexMatches) => {
|
||||
const dataType = sortedDataTypeMap[databaseType].find(
|
||||
(v) => v.id === value
|
||||
) ?? {
|
||||
id: value as string,
|
||||
name: value as string,
|
||||
};
|
||||
|
||||
let characterMaximumLength: string | undefined = undefined;
|
||||
let precision: number | undefined = undefined;
|
||||
let scale: number | undefined = undefined;
|
||||
let isArray: boolean | undefined = undefined;
|
||||
|
||||
if (regexMatches?.length) {
|
||||
// Check if the last captured group is the array indicator []
|
||||
const lastMatch = regexMatches[regexMatches.length - 1];
|
||||
const hasArrayIndicator = lastMatch === '[]';
|
||||
|
||||
if (dataType?.fieldAttributes?.hasCharMaxLength) {
|
||||
characterMaximumLength = regexMatches[1]?.toLowerCase();
|
||||
} else if (
|
||||
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]);
|
||||
}
|
||||
|
||||
// Set isArray if the array indicator was found and the type supports arrays
|
||||
if (hasArrayIndicator) {
|
||||
const typeId = value as string;
|
||||
if (supportsArrayDataType(typeId, databaseType)) {
|
||||
isArray = true;
|
||||
}
|
||||
} else {
|
||||
// Explicitly set to false/undefined if no array indicator
|
||||
isArray = undefined;
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
dataType?.fieldAttributes?.hasCharMaxLength &&
|
||||
field.characterMaximumLength
|
||||
) {
|
||||
characterMaximumLength = field.characterMaximumLength;
|
||||
}
|
||||
|
||||
if (dataType?.fieldAttributes?.precision && field.precision) {
|
||||
precision = field.precision;
|
||||
}
|
||||
|
||||
if (dataType?.fieldAttributes?.scale && field.scale) {
|
||||
scale = field.scale;
|
||||
}
|
||||
}
|
||||
|
||||
const newTypeName = dataType?.name ?? (value as string);
|
||||
const typeRequiresNotNull = requiresNotNull(newTypeName);
|
||||
const shouldForceIncrement = autoIncrementAlwaysOn(newTypeName);
|
||||
|
||||
updateField(table.id, field.id, {
|
||||
characterMaximumLength,
|
||||
precision,
|
||||
scale,
|
||||
isArray,
|
||||
...(typeRequiresNotNull ? { nullable: false } : {}),
|
||||
increment: shouldForceIncrement ? true : undefined,
|
||||
default: undefined,
|
||||
type: dataTypeDataToDataType(
|
||||
dataType ?? {
|
||||
id: value as string,
|
||||
name: value as string,
|
||||
}
|
||||
),
|
||||
});
|
||||
},
|
||||
[
|
||||
updateField,
|
||||
databaseType,
|
||||
field.characterMaximumLength,
|
||||
field.precision,
|
||||
field.scale,
|
||||
field.id,
|
||||
table.id,
|
||||
]
|
||||
);
|
||||
|
||||
// Debounced update for field name
|
||||
const debouncedNameUpdate = useDebounce(
|
||||
useCallback(
|
||||
(value: string) => {
|
||||
if (value.trim() !== field.name) {
|
||||
updateField(table.id, field.id, { name: value });
|
||||
}
|
||||
},
|
||||
[updateField, table.id, field.id, field.name]
|
||||
),
|
||||
300 // 300ms debounce for text input
|
||||
);
|
||||
|
||||
// Debounced update for nullable toggle
|
||||
const debouncedNullableUpdate = useDebounce(
|
||||
useCallback(
|
||||
(value: boolean) => {
|
||||
const updates: Partial<DBField> = { nullable: value };
|
||||
|
||||
// If setting to nullable, clear increment (auto-increment requires NOT NULL)
|
||||
if (value && field.increment) {
|
||||
updates.increment = undefined;
|
||||
}
|
||||
|
||||
updateField(table.id, field.id, updates);
|
||||
},
|
||||
[updateField, table.id, field.id, field.increment]
|
||||
),
|
||||
100 // 100ms debounce for toggle
|
||||
);
|
||||
|
||||
// Debounced update for primary key toggle
|
||||
const debouncedPrimaryKeyUpdate = useDebounce(
|
||||
useCallback(
|
||||
(value: boolean, primaryKeyCount: number) => {
|
||||
if (value) {
|
||||
// When setting as primary key
|
||||
const updates: Partial<DBField> = {
|
||||
primaryKey: true,
|
||||
};
|
||||
// Only auto-set unique if this will be the only primary key
|
||||
if (primaryKeyCount === 0) {
|
||||
updates.unique = true;
|
||||
}
|
||||
updateField(table.id, field.id, updates);
|
||||
} else {
|
||||
// When removing primary key
|
||||
updateField(table.id, field.id, {
|
||||
primaryKey: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[updateField, table.id, field.id]
|
||||
),
|
||||
100 // 100ms debounce for toggle
|
||||
);
|
||||
|
||||
// Handle primary key toggle with optimistic update
|
||||
const handlePrimaryKeyToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
setLocalPrimaryKey(value);
|
||||
debouncedPrimaryKeyUpdate(value, primaryKeyCount);
|
||||
},
|
||||
[primaryKeyCount, debouncedPrimaryKeyUpdate]
|
||||
);
|
||||
|
||||
// Handle nullable toggle with optimistic update
|
||||
const handleNullableToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
setLocalNullable(value);
|
||||
debouncedNullableUpdate(value);
|
||||
},
|
||||
[debouncedNullableUpdate]
|
||||
);
|
||||
|
||||
// Handle name change with optimistic update
|
||||
const handleNameChange = useCallback(
|
||||
(value: string) => {
|
||||
setLocalFieldName(value);
|
||||
debouncedNameUpdate(value);
|
||||
},
|
||||
[debouncedNameUpdate]
|
||||
);
|
||||
|
||||
// Utility function to generate field suffix for display
|
||||
const generateFieldSuffix = useCallback(
|
||||
(typeId?: string) => {
|
||||
return generateDBFieldSuffix(
|
||||
{
|
||||
...field,
|
||||
isArray: field.isArray && typeId === field.type.id,
|
||||
},
|
||||
{
|
||||
databaseType,
|
||||
forceExtended: true,
|
||||
typeId,
|
||||
}
|
||||
);
|
||||
},
|
||||
[field, databaseType]
|
||||
);
|
||||
|
||||
const removeField = useCallback(() => {
|
||||
chartDBRemoveField(table.id, field.id);
|
||||
}, [chartDBRemoveField, table.id, field.id]);
|
||||
|
||||
return {
|
||||
dataFieldOptions,
|
||||
handleDataTypeChange,
|
||||
handlePrimaryKeyToggle,
|
||||
handleNullableToggle,
|
||||
handleNameChange,
|
||||
generateFieldSuffix,
|
||||
primaryKeyCount,
|
||||
fieldName: localFieldName,
|
||||
nullable: localNullable,
|
||||
primaryKey: localPrimaryKey,
|
||||
removeField,
|
||||
};
|
||||
};
|
||||
42
src/hooks/use-update-table.ts
Normal file
42
src/hooks/use-update-table.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { useChartDB } from './use-chartdb';
|
||||
import { useDebounce } from './use-debounce-v2';
|
||||
import type { DBTable } from '@/lib/domain';
|
||||
|
||||
// Hook for updating table properties with debouncing for performance
|
||||
export const useUpdateTable = (table: DBTable) => {
|
||||
const { updateTable: chartDBUpdateTable } = useChartDB();
|
||||
const [localTableName, setLocalTableName] = useState(table.name);
|
||||
|
||||
// Debounced update function
|
||||
const debouncedUpdate = useDebounce(
|
||||
useCallback(
|
||||
(value: string) => {
|
||||
if (value.trim() && value.trim() !== table.name) {
|
||||
chartDBUpdateTable(table.id, { name: value.trim() });
|
||||
}
|
||||
},
|
||||
[chartDBUpdateTable, table.id, table.name]
|
||||
),
|
||||
1000 // 1000ms debounce
|
||||
);
|
||||
|
||||
// Update local state immediately for responsive UI
|
||||
const handleTableNameChange = useCallback(
|
||||
(value: string) => {
|
||||
setLocalTableName(value);
|
||||
debouncedUpdate(value);
|
||||
},
|
||||
[debouncedUpdate]
|
||||
);
|
||||
|
||||
// Update local state when table name changes externally
|
||||
useEffect(() => {
|
||||
setLocalTableName(table.name);
|
||||
}, [table.name]);
|
||||
|
||||
return {
|
||||
tableName: localTableName,
|
||||
handleTableNameChange,
|
||||
};
|
||||
};
|
||||
@@ -7,9 +7,9 @@ export const ar: LanguageTranslation = {
|
||||
browse: 'تصفح',
|
||||
tables: 'الجداول',
|
||||
refs: 'المراجع',
|
||||
areas: 'المناطق',
|
||||
dependencies: 'التبعيات',
|
||||
custom_types: 'الأنواع المخصصة',
|
||||
visuals: 'مرئيات',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -232,6 +232,33 @@ export const ar: LanguageTranslation = {
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'مرئيات',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'ملاحظات',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'تصفية',
|
||||
add_note: 'إضافة ملاحظة',
|
||||
no_results: 'لم يتم العثور على ملاحظات',
|
||||
clear: 'مسح التصفية',
|
||||
empty_state: {
|
||||
title: 'لا توجد ملاحظات',
|
||||
description: 'أنشئ ملاحظة لإضافة تعليقات نصية على اللوحة',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'ملاحظة فارغة',
|
||||
note_actions: {
|
||||
title: 'إجراءات الملاحظة',
|
||||
edit_content: 'تحرير المحتوى',
|
||||
delete_note: 'حذف الملاحظة',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -308,7 +335,7 @@ export const ar: LanguageTranslation = {
|
||||
cancel: 'إلغاء',
|
||||
import_from_file: 'استيراد من ملف',
|
||||
back: 'رجوع',
|
||||
empty_diagram: 'مخطط فارغ',
|
||||
empty_diagram: 'قاعدة بيانات فارغة',
|
||||
continue: 'متابعة',
|
||||
import: 'استيراد',
|
||||
},
|
||||
@@ -479,6 +506,7 @@ export const ar: LanguageTranslation = {
|
||||
new_relationship: 'علاقة جديدة',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'ملاحظة جديدة',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const bn: LanguageTranslation = {
|
||||
browse: 'ব্রাউজ',
|
||||
tables: 'টেবিল',
|
||||
refs: 'রেফস',
|
||||
areas: 'এলাকা',
|
||||
dependencies: 'নির্ভরতা',
|
||||
custom_types: 'কাস্টম টাইপ',
|
||||
visuals: 'ভিজ্যুয়াল',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -233,6 +233,35 @@ export const bn: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'ভিজ্যুয়াল',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'নোট',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'ফিল্টার',
|
||||
add_note: 'নোট যোগ করুন',
|
||||
no_results: 'কোনো নোট পাওয়া যায়নি',
|
||||
clear: 'ফিল্টার সাফ করুন',
|
||||
empty_state: {
|
||||
title: 'কোনো নোট নেই',
|
||||
description:
|
||||
'ক্যানভাসে টেক্সট টীকা যোগ করতে একটি নোট তৈরি করুন',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'খালি নোট',
|
||||
note_actions: {
|
||||
title: 'নোট ক্রিয়া',
|
||||
edit_content: 'বিষয়বস্তু সম্পাদনা',
|
||||
delete_note: 'নোট মুছুন',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -310,7 +339,7 @@ export const bn: LanguageTranslation = {
|
||||
cancel: 'বাতিল করুন',
|
||||
back: 'ফিরে যান',
|
||||
import_from_file: 'ফাইল থেকে আমদানি করুন',
|
||||
empty_diagram: 'ফাঁকা চিত্র',
|
||||
empty_diagram: 'খালি ডাটাবেস',
|
||||
continue: 'চালিয়ে যান',
|
||||
import: 'আমদানি করুন',
|
||||
},
|
||||
@@ -484,6 +513,7 @@ export const bn: LanguageTranslation = {
|
||||
new_relationship: 'নতুন সম্পর্ক',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'নতুন নোট',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const de: LanguageTranslation = {
|
||||
browse: 'Durchsuchen',
|
||||
tables: 'Tabellen',
|
||||
refs: 'Refs',
|
||||
areas: 'Bereiche',
|
||||
dependencies: 'Abhängigkeiten',
|
||||
custom_types: 'Benutzerdefinierte Typen',
|
||||
visuals: 'Darstellungen',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -234,6 +234,35 @@ export const de: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Darstellungen',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'Notizen',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'Filter',
|
||||
add_note: 'Notiz hinzufügen',
|
||||
no_results: 'Keine Notizen gefunden',
|
||||
clear: 'Filter löschen',
|
||||
empty_state: {
|
||||
title: 'Keine Notizen',
|
||||
description:
|
||||
'Erstellen Sie eine Notiz, um Textanmerkungen auf der Leinwand hinzuzufügen',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'Leere Notiz',
|
||||
note_actions: {
|
||||
title: 'Notiz-Aktionen',
|
||||
edit_content: 'Inhalt bearbeiten',
|
||||
delete_note: 'Notiz löschen',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -313,7 +342,7 @@ export const de: LanguageTranslation = {
|
||||
back: 'Zurück',
|
||||
// TODO: Translate
|
||||
import_from_file: 'Import from File',
|
||||
empty_diagram: 'Leeres Diagramm',
|
||||
empty_diagram: 'Leere Datenbank',
|
||||
continue: 'Weiter',
|
||||
import: 'Importieren',
|
||||
},
|
||||
@@ -487,6 +516,7 @@ export const de: LanguageTranslation = {
|
||||
new_relationship: 'Neue Beziehung',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'Neue Notiz',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const en = {
|
||||
browse: 'Browse',
|
||||
tables: 'Tables',
|
||||
refs: 'Refs',
|
||||
areas: 'Areas',
|
||||
dependencies: 'Dependencies',
|
||||
custom_types: 'Custom Types',
|
||||
visuals: 'Visuals',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -227,6 +227,34 @@ export const en = {
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Visuals',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'Notes',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'Filter',
|
||||
add_note: 'Add Note',
|
||||
no_results: 'No notes found',
|
||||
clear: 'Clear Filter',
|
||||
empty_state: {
|
||||
title: 'No Notes',
|
||||
description:
|
||||
'Create a note to add text annotations on the canvas',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'Empty note',
|
||||
note_actions: {
|
||||
title: 'Note Actions',
|
||||
edit_content: 'Edit Content',
|
||||
delete_note: 'Delete Note',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
filter: 'Filter',
|
||||
@@ -301,7 +329,7 @@ export const en = {
|
||||
cancel: 'Cancel',
|
||||
import_from_file: 'Import from File',
|
||||
back: 'Back',
|
||||
empty_diagram: 'Empty diagram',
|
||||
empty_diagram: 'Empty database',
|
||||
continue: 'Continue',
|
||||
import: 'Import',
|
||||
},
|
||||
@@ -473,6 +501,7 @@ export const en = {
|
||||
new_view: 'New View',
|
||||
new_relationship: 'New Relationship',
|
||||
new_area: 'New Area',
|
||||
new_note: 'New Note',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const es: LanguageTranslation = {
|
||||
browse: 'Examinar',
|
||||
tables: 'Tablas',
|
||||
refs: 'Refs',
|
||||
areas: 'Áreas',
|
||||
dependencies: 'Dependencias',
|
||||
custom_types: 'Tipos Personalizados',
|
||||
visuals: 'Visuales',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -232,6 +232,35 @@ export const es: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Visuales',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'Notas',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'Filtrar',
|
||||
add_note: 'Agregar Nota',
|
||||
no_results: 'No se encontraron notas',
|
||||
clear: 'Limpiar Filtro',
|
||||
empty_state: {
|
||||
title: 'Sin Notas',
|
||||
description:
|
||||
'Crea una nota para agregar anotaciones de texto en el lienzo',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'Nota vacía',
|
||||
note_actions: {
|
||||
title: 'Acciones de Nota',
|
||||
edit_content: 'Editar Contenido',
|
||||
delete_note: 'Eliminar Nota',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -310,7 +339,7 @@ export const es: LanguageTranslation = {
|
||||
back: 'Atrás',
|
||||
// TODO: Translate
|
||||
import_from_file: 'Import from File',
|
||||
empty_diagram: 'Diagrama vacío',
|
||||
empty_diagram: 'Base de datos vacía',
|
||||
continue: 'Continuar',
|
||||
import: 'Importar',
|
||||
},
|
||||
@@ -486,6 +515,7 @@ export const es: LanguageTranslation = {
|
||||
new_relationship: 'Nueva Relación',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'Nueva Nota',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const fr: LanguageTranslation = {
|
||||
browse: 'Parcourir',
|
||||
tables: 'Tables',
|
||||
refs: 'Refs',
|
||||
areas: 'Zones',
|
||||
dependencies: 'Dépendances',
|
||||
custom_types: 'Types Personnalisés',
|
||||
visuals: 'Visuels',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -230,6 +230,35 @@ export const fr: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Visuels',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'Notes',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'Filtrer',
|
||||
add_note: 'Ajouter une Note',
|
||||
no_results: 'Aucune note trouvée',
|
||||
clear: 'Effacer le Filtre',
|
||||
empty_state: {
|
||||
title: 'Pas de Notes',
|
||||
description:
|
||||
'Créez une note pour ajouter des annotations de texte sur le canevas',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'Note vide',
|
||||
note_actions: {
|
||||
title: 'Actions de Note',
|
||||
edit_content: 'Modifier le Contenu',
|
||||
delete_note: 'Supprimer la Note',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -307,7 +336,7 @@ export const fr: LanguageTranslation = {
|
||||
cancel: 'Annuler',
|
||||
back: 'Retour',
|
||||
import_from_file: "Importer à partir d'un fichier",
|
||||
empty_diagram: 'Diagramme vide',
|
||||
empty_diagram: 'Base de données vide',
|
||||
continue: 'Continuer',
|
||||
import: 'Importer',
|
||||
},
|
||||
@@ -482,6 +511,7 @@ export const fr: LanguageTranslation = {
|
||||
new_relationship: 'Nouvelle Relation',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'Nouvelle Note',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const gu: LanguageTranslation = {
|
||||
browse: 'બ્રાઉજ',
|
||||
tables: 'ટેબલો',
|
||||
refs: 'રેફ્સ',
|
||||
areas: 'ક્ષેત્રો',
|
||||
dependencies: 'નિર્ભરતાઓ',
|
||||
custom_types: 'કસ્ટમ ટાઇપ',
|
||||
visuals: 'Visuals',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -234,6 +234,35 @@ export const gu: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Visuals',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'નોંધો',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'ફિલ્ટર',
|
||||
add_note: 'નોંધ ઉમેરો',
|
||||
no_results: 'કોઈ નોંધો મળી નથી',
|
||||
clear: 'ફિલ્ટર સાફ કરો',
|
||||
empty_state: {
|
||||
title: 'કોઈ નોંધો નથી',
|
||||
description:
|
||||
'કેનવાસ પર ટેક્સ્ટ એનોટેશન ઉમેરવા માટે નોંધ બનાવો',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'ખાલી નોંધ',
|
||||
note_actions: {
|
||||
title: 'નોંધ ક્રિયાઓ',
|
||||
edit_content: 'સામગ્રી સંપાદિત કરો',
|
||||
delete_note: 'નોંધ કાઢી નાખો',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -310,7 +339,7 @@ export const gu: LanguageTranslation = {
|
||||
cancel: 'રદ કરો',
|
||||
back: 'પાછા',
|
||||
import_from_file: 'ફાઇલમાંથી આયાત કરો',
|
||||
empty_diagram: 'ખાલી ડાયાગ્રામ',
|
||||
empty_diagram: 'ખાલી ડેટાબેસ',
|
||||
continue: 'ચાલુ રાખો',
|
||||
import: 'આયાત કરો',
|
||||
},
|
||||
@@ -485,6 +514,7 @@ export const gu: LanguageTranslation = {
|
||||
new_relationship: 'નવો સંબંધ',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'નવી નોંધ',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const hi: LanguageTranslation = {
|
||||
browse: 'ब्राउज़',
|
||||
tables: 'टेबल',
|
||||
refs: 'रेफ्स',
|
||||
areas: 'क्षेत्र',
|
||||
dependencies: 'निर्भरताएं',
|
||||
custom_types: 'कस्टम टाइप',
|
||||
visuals: 'Visuals',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -233,6 +233,35 @@ export const hi: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Visuals',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'नोट्स',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'फ़िल्टर',
|
||||
add_note: 'नोट जोड़ें',
|
||||
no_results: 'कोई नोट नहीं मिला',
|
||||
clear: 'फ़िल्टर साफ़ करें',
|
||||
empty_state: {
|
||||
title: 'कोई नोट नहीं',
|
||||
description:
|
||||
'कैनवास पर टेक्स्ट एनोटेशन जोड़ने के लिए एक नोट बनाएं',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'खाली नोट',
|
||||
note_actions: {
|
||||
title: 'नोट क्रियाएं',
|
||||
edit_content: 'सामग्री संपादित करें',
|
||||
delete_note: 'नोट हटाएं',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -312,7 +341,7 @@ export const hi: LanguageTranslation = {
|
||||
back: 'वापस',
|
||||
// TODO: Translate
|
||||
import_from_file: 'Import from File',
|
||||
empty_diagram: 'खाली आरेख',
|
||||
empty_diagram: 'खाली डेटाबेस',
|
||||
continue: 'जारी रखें',
|
||||
import: 'आयात करें',
|
||||
},
|
||||
@@ -487,6 +516,7 @@ export const hi: LanguageTranslation = {
|
||||
new_relationship: 'नया संबंध',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'नया नोट',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const hr: LanguageTranslation = {
|
||||
browse: 'Pregledaj',
|
||||
tables: 'Tablice',
|
||||
refs: 'Refs',
|
||||
areas: 'Područja',
|
||||
dependencies: 'Ovisnosti',
|
||||
custom_types: 'Prilagođeni Tipovi',
|
||||
visuals: 'Vizuali',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -229,6 +229,34 @@ export const hr: LanguageTranslation = {
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Vizuali',
|
||||
tabs: {
|
||||
areas: 'Područja',
|
||||
notes: 'Bilješke',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'Filtriraj',
|
||||
add_note: 'Dodaj Bilješku',
|
||||
no_results: 'Nije pronađena nijedna bilješka',
|
||||
clear: 'Očisti Filter',
|
||||
empty_state: {
|
||||
title: 'Nema Bilješki',
|
||||
description:
|
||||
'Kreirajte bilješku za dodavanje tekstualnih napomena na platnu',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'Prazna bilješka',
|
||||
note_actions: {
|
||||
title: 'Akcije Bilješke',
|
||||
edit_content: 'Uredi Sadržaj',
|
||||
delete_note: 'Obriši Bilješku',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
custom_types_section: {
|
||||
custom_types: 'Prilagođeni tipovi',
|
||||
filter: 'Filtriraj',
|
||||
@@ -305,7 +333,7 @@ export const hr: LanguageTranslation = {
|
||||
cancel: 'Odustani',
|
||||
import_from_file: 'Uvezi iz datoteke',
|
||||
back: 'Natrag',
|
||||
empty_diagram: 'Prazan dijagram',
|
||||
empty_diagram: 'Prazna baza podataka',
|
||||
continue: 'Nastavi',
|
||||
import: 'Uvezi',
|
||||
},
|
||||
@@ -478,6 +506,7 @@ export const hr: LanguageTranslation = {
|
||||
new_view: 'Novi Pogled',
|
||||
new_relationship: 'Nova veza',
|
||||
new_area: 'Novo područje',
|
||||
new_note: 'Nova Bilješka',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const id_ID: LanguageTranslation = {
|
||||
browse: 'Jelajahi',
|
||||
tables: 'Tabel',
|
||||
refs: 'Refs',
|
||||
areas: 'Area',
|
||||
dependencies: 'Ketergantungan',
|
||||
custom_types: 'Tipe Kustom',
|
||||
visuals: 'Visual',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -232,6 +232,35 @@ export const id_ID: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Visual',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'Catatan',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'Filter',
|
||||
add_note: 'Tambah Catatan',
|
||||
no_results: 'Tidak ada catatan ditemukan',
|
||||
clear: 'Hapus Filter',
|
||||
empty_state: {
|
||||
title: 'Tidak Ada Catatan',
|
||||
description:
|
||||
'Buat catatan untuk menambahkan anotasi teks di kanvas',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'Catatan kosong',
|
||||
note_actions: {
|
||||
title: 'Aksi Catatan',
|
||||
edit_content: 'Edit Konten',
|
||||
delete_note: 'Hapus Catatan',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -309,7 +338,7 @@ export const id_ID: LanguageTranslation = {
|
||||
cancel: 'Batal',
|
||||
import_from_file: 'Impor dari file',
|
||||
back: 'Kembali',
|
||||
empty_diagram: 'Diagram Kosong',
|
||||
empty_diagram: 'Database Kosong',
|
||||
continue: 'Lanjutkan',
|
||||
import: 'Impor',
|
||||
},
|
||||
@@ -484,6 +513,7 @@ export const id_ID: LanguageTranslation = {
|
||||
new_relationship: 'Hubungan Baru',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'Catatan Baru',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const ja: LanguageTranslation = {
|
||||
browse: '参照',
|
||||
tables: 'テーブル',
|
||||
refs: '参照',
|
||||
areas: 'エリア',
|
||||
dependencies: '依存関係',
|
||||
custom_types: 'カスタムタイプ',
|
||||
visuals: 'ビジュアル',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -237,6 +237,35 @@ export const ja: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'ビジュアル',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'ノート',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'フィルター',
|
||||
add_note: 'ノートを追加',
|
||||
no_results: 'ノートが見つかりません',
|
||||
clear: 'フィルターをクリア',
|
||||
empty_state: {
|
||||
title: 'ノートがありません',
|
||||
description:
|
||||
'キャンバス上にテキスト注釈を追加するためのノートを作成',
|
||||
},
|
||||
note: {
|
||||
empty_note: '空のノート',
|
||||
note_actions: {
|
||||
title: 'ノートアクション',
|
||||
edit_content: 'コンテンツを編集',
|
||||
delete_note: 'ノートを削除',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -314,7 +343,7 @@ export const ja: LanguageTranslation = {
|
||||
back: '戻る',
|
||||
// TODO: Translate
|
||||
import_from_file: 'Import from File',
|
||||
empty_diagram: '空のダイアグラム',
|
||||
empty_diagram: '空のデータベース',
|
||||
continue: '続行',
|
||||
import: 'インポート',
|
||||
},
|
||||
@@ -489,6 +518,7 @@ export const ja: LanguageTranslation = {
|
||||
new_relationship: '新しいリレーションシップ',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: '新しいメモ',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const ko_KR: LanguageTranslation = {
|
||||
browse: '찾아보기',
|
||||
tables: '테이블',
|
||||
refs: 'Refs',
|
||||
areas: '영역',
|
||||
dependencies: '종속성',
|
||||
custom_types: '사용자 지정 타입',
|
||||
visuals: '시각화',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -232,6 +232,35 @@ export const ko_KR: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: '시각화',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: '메모',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: '필터',
|
||||
add_note: '메모 추가',
|
||||
no_results: '메모를 찾을 수 없습니다',
|
||||
clear: '필터 지우기',
|
||||
empty_state: {
|
||||
title: '메모 없음',
|
||||
description:
|
||||
'캔버스에 텍스트 주석을 추가하려면 메모를 만드세요',
|
||||
},
|
||||
note: {
|
||||
empty_note: '빈 메모',
|
||||
note_actions: {
|
||||
title: '메모 작업',
|
||||
edit_content: '내용 편집',
|
||||
delete_note: '메모 삭제',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -309,7 +338,7 @@ export const ko_KR: LanguageTranslation = {
|
||||
cancel: '취소',
|
||||
back: '뒤로가기',
|
||||
import_from_file: '파일에서 가져오기',
|
||||
empty_diagram: '빈 다이어그램으로 시작',
|
||||
empty_diagram: '빈 데이터베이스',
|
||||
continue: '계속',
|
||||
import: '가져오기',
|
||||
},
|
||||
@@ -481,6 +510,7 @@ export const ko_KR: LanguageTranslation = {
|
||||
new_relationship: '새 연관관계',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: '새 메모',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const mr: LanguageTranslation = {
|
||||
browse: 'ब्राउज',
|
||||
tables: 'टेबल',
|
||||
refs: 'Refs',
|
||||
areas: 'क्षेत्रे',
|
||||
dependencies: 'अवलंबने',
|
||||
custom_types: 'कस्टम प्रकार',
|
||||
visuals: 'Visuals',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -236,6 +236,35 @@ export const mr: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Visuals',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'नोट्स',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'फिल्टर',
|
||||
add_note: 'नोट जोडा',
|
||||
no_results: 'कोणत्याही नोट्स सापडल्या नाहीत',
|
||||
clear: 'फिल्टर साफ करा',
|
||||
empty_state: {
|
||||
title: 'नोट्स नाहीत',
|
||||
description:
|
||||
'कॅनव्हासवर मजकूर भाष्य जोडण्यासाठी एक नोट तयार करा',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'रिकामी नोट',
|
||||
note_actions: {
|
||||
title: 'नोट क्रिया',
|
||||
edit_content: 'सामग्री संपादित करा',
|
||||
delete_note: 'नोट हटवा',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -315,7 +344,7 @@ export const mr: LanguageTranslation = {
|
||||
// TODO: Add translations
|
||||
import_from_file: 'Import from File',
|
||||
back: 'मागे',
|
||||
empty_diagram: 'रिक्त आरेख',
|
||||
empty_diagram: 'रिक्त डेटाबेस',
|
||||
continue: 'सुरू ठेवा',
|
||||
import: 'आयात करा',
|
||||
},
|
||||
@@ -493,6 +522,7 @@ export const mr: LanguageTranslation = {
|
||||
new_relationship: 'नवीन रिलेशनशिप',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'नवीन टीप',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const ne: LanguageTranslation = {
|
||||
browse: 'ब्राउज',
|
||||
tables: 'टेबलहरू',
|
||||
refs: 'Refs',
|
||||
areas: 'क्षेत्रहरू',
|
||||
dependencies: 'निर्भरताहरू',
|
||||
custom_types: 'कस्टम प्रकारहरू',
|
||||
visuals: 'Visuals',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -233,6 +233,35 @@ export const ne: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Visuals',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'टिप्पणीहरू',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'फिल्टर',
|
||||
add_note: 'टिप्पणी थप्नुहोस्',
|
||||
no_results: 'कुनै टिप्पणी फेला परेन',
|
||||
clear: 'फिल्टर खाली गर्नुहोस्',
|
||||
empty_state: {
|
||||
title: 'कुनै टिप्पणी छैन',
|
||||
description:
|
||||
'क्यानभासमा पाठ टिप्पणी थप्न टिप्पणी सिर्जना गर्नुहोस्',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'खाली टिप्पणी',
|
||||
note_actions: {
|
||||
title: 'टिप्पणी कार्यहरू',
|
||||
edit_content: 'सामग्री सम्पादन गर्नुहोस्',
|
||||
delete_note: 'टिप्पणी मेटाउनुहोस्',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -311,7 +340,7 @@ export const ne: LanguageTranslation = {
|
||||
cancel: 'रद्द गर्नुहोस्',
|
||||
import_from_file: 'फाइलबाट आयात गर्नुहोस्',
|
||||
back: 'फर्क',
|
||||
empty_diagram: 'रिक्त डायाग्राम',
|
||||
empty_diagram: 'खाली डाटाबेस',
|
||||
continue: 'जारी राख्नुहोस्',
|
||||
import: 'आयात गर्नुहोस्',
|
||||
},
|
||||
@@ -487,6 +516,7 @@ export const ne: LanguageTranslation = {
|
||||
new_relationship: 'नयाँ सम्बन्ध',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'नयाँ नोट',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const pt_BR: LanguageTranslation = {
|
||||
browse: 'Navegar',
|
||||
tables: 'Tabelas',
|
||||
refs: 'Refs',
|
||||
areas: 'Áreas',
|
||||
dependencies: 'Dependências',
|
||||
custom_types: 'Tipos Personalizados',
|
||||
visuals: 'Visuais',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -233,6 +233,35 @@ export const pt_BR: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Visuais',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'Notas',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'Filtrar',
|
||||
add_note: 'Adicionar Nota',
|
||||
no_results: 'Nenhuma nota encontrada',
|
||||
clear: 'Limpar Filtro',
|
||||
empty_state: {
|
||||
title: 'Sem Notas',
|
||||
description:
|
||||
'Crie uma nota para adicionar anotações de texto na tela',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'Nota vazia',
|
||||
note_actions: {
|
||||
title: 'Ações de Nota',
|
||||
edit_content: 'Editar Conteúdo',
|
||||
delete_note: 'Excluir Nota',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -311,7 +340,7 @@ export const pt_BR: LanguageTranslation = {
|
||||
back: 'Voltar',
|
||||
// TODO: Translate
|
||||
import_from_file: 'Import from File',
|
||||
empty_diagram: 'Diagrama vazio',
|
||||
empty_diagram: 'Banco de dados vazio',
|
||||
continue: 'Continuar',
|
||||
import: 'Importar',
|
||||
},
|
||||
@@ -486,6 +515,7 @@ export const pt_BR: LanguageTranslation = {
|
||||
new_relationship: 'Novo Relacionamento',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'Nova Nota',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const ru: LanguageTranslation = {
|
||||
browse: 'Обзор',
|
||||
tables: 'Таблицы',
|
||||
refs: 'Ссылки',
|
||||
areas: 'Области',
|
||||
dependencies: 'Зависимости',
|
||||
custom_types: 'Пользовательские типы',
|
||||
visuals: 'Визуальные элементы',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -230,6 +230,35 @@ export const ru: LanguageTranslation = {
|
||||
description: 'Создайте область, чтобы начать',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Визуальные элементы',
|
||||
tabs: {
|
||||
areas: 'Области',
|
||||
notes: 'Заметки',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'Фильтр',
|
||||
add_note: 'Добавить Заметку',
|
||||
no_results: 'Заметки не найдены',
|
||||
clear: 'Очистить Фильтр',
|
||||
empty_state: {
|
||||
title: 'Нет Заметок',
|
||||
description:
|
||||
'Создайте заметку, чтобы добавить текстовые аннотации на холсте',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'Пустая заметка',
|
||||
note_actions: {
|
||||
title: 'Действия с Заметкой',
|
||||
edit_content: 'Редактировать Содержимое',
|
||||
delete_note: 'Удалить Заметку',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -307,7 +336,7 @@ export const ru: LanguageTranslation = {
|
||||
cancel: 'Отменить',
|
||||
back: 'Назад',
|
||||
import_from_file: 'Импортировать из файла',
|
||||
empty_diagram: 'Пустая диаграмма',
|
||||
empty_diagram: 'Пустая база данных',
|
||||
continue: 'Продолжить',
|
||||
import: 'Импорт',
|
||||
},
|
||||
@@ -481,6 +510,7 @@ export const ru: LanguageTranslation = {
|
||||
new_view: 'Новое представление',
|
||||
new_relationship: 'Создать отношение',
|
||||
new_area: 'Новая область',
|
||||
new_note: 'Новая Заметка',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const te: LanguageTranslation = {
|
||||
browse: 'బ్రాఉజ్',
|
||||
tables: 'టేబల్లు',
|
||||
refs: 'సంబంధాలు',
|
||||
areas: 'ప్రదేశాలు',
|
||||
dependencies: 'ఆధారతలు',
|
||||
custom_types: 'కస్టమ్ టైప్స్',
|
||||
visuals: 'Visuals',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -234,6 +234,35 @@ export const te: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Visuals',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'గమనికలు',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'ఫిల్టర్',
|
||||
add_note: 'గమనిక జోడించండి',
|
||||
no_results: 'గమనికలు కనుగొనబడలేదు',
|
||||
clear: 'ఫిల్టర్ను క్లియర్ చేయండి',
|
||||
empty_state: {
|
||||
title: 'గమనికలు లేవు',
|
||||
description:
|
||||
'కాన్వాస్పై టెక్స్ట్ ఉల్లేఖనలను జోడించడానికి ఒక గమనికను సృష్టించండి',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'ఖాళీ గమనిక',
|
||||
note_actions: {
|
||||
title: 'గమనిక చర్యలు',
|
||||
edit_content: 'కంటెంట్ను సవరించండి',
|
||||
delete_note: 'గమనికను తొలగించండి',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -312,7 +341,7 @@ export const te: LanguageTranslation = {
|
||||
// TODO: Translate
|
||||
import_from_file: 'Import from File',
|
||||
back: 'తిరుగు',
|
||||
empty_diagram: 'ఖాళీ చిత్రము',
|
||||
empty_diagram: 'ఖాళీ డేటాబేస్',
|
||||
continue: 'కొనసాగించు',
|
||||
import: 'డిగుమతి',
|
||||
},
|
||||
@@ -490,6 +519,7 @@ export const te: LanguageTranslation = {
|
||||
new_relationship: 'కొత్త సంబంధం',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'కొత్త నోట్',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const tr: LanguageTranslation = {
|
||||
browse: 'Gözat',
|
||||
tables: 'Tablolar',
|
||||
refs: 'Refs',
|
||||
areas: 'Alanlar',
|
||||
dependencies: 'Bağımlılıklar',
|
||||
custom_types: 'Özel Tipler',
|
||||
visuals: 'Görseller',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -233,6 +233,35 @@ export const tr: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Görseller',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'Notlar',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'Filtrele',
|
||||
add_note: 'Not Ekle',
|
||||
no_results: 'Not bulunamadı',
|
||||
clear: 'Filtreyi Temizle',
|
||||
empty_state: {
|
||||
title: 'Not Yok',
|
||||
description:
|
||||
'Tuval üzerinde metin açıklamaları eklemek için bir not oluşturun',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'Boş not',
|
||||
note_actions: {
|
||||
title: 'Not İşlemleri',
|
||||
edit_content: 'İçeriği Düzenle',
|
||||
delete_note: 'Notu Sil',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -308,7 +337,7 @@ export const tr: LanguageTranslation = {
|
||||
import_from_file: 'Import from File',
|
||||
cancel: 'İptal',
|
||||
back: 'Geri',
|
||||
empty_diagram: 'Boş diyagram',
|
||||
empty_diagram: 'Boş veritabanı',
|
||||
continue: 'Devam',
|
||||
import: 'İçe Aktar',
|
||||
},
|
||||
@@ -475,6 +504,7 @@ export const tr: LanguageTranslation = {
|
||||
new_relationship: 'Yeni İlişki',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'Yeni Not',
|
||||
},
|
||||
table_node_context_menu: {
|
||||
edit_table: 'Tabloyu Düzenle',
|
||||
|
||||
@@ -7,9 +7,9 @@ export const uk: LanguageTranslation = {
|
||||
browse: 'Огляд',
|
||||
tables: 'Таблиці',
|
||||
refs: 'Зв’язки',
|
||||
areas: 'Області',
|
||||
dependencies: 'Залежності',
|
||||
custom_types: 'Користувацькі типи',
|
||||
visuals: 'Візуальні елементи',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -231,6 +231,35 @@ export const uk: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Візуальні елементи',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'Нотатки',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'Фільтр',
|
||||
add_note: 'Додати Нотатку',
|
||||
no_results: 'Нотатки не знайдено',
|
||||
clear: 'Очистити Фільтр',
|
||||
empty_state: {
|
||||
title: 'Немає Нотаток',
|
||||
description:
|
||||
'Створіть нотатку, щоб додати текстові анотації на полотні',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'Порожня нотатка',
|
||||
note_actions: {
|
||||
title: 'Дії з Нотаткою',
|
||||
edit_content: 'Редагувати Вміст',
|
||||
delete_note: 'Видалити Нотатку',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -308,7 +337,7 @@ export const uk: LanguageTranslation = {
|
||||
cancel: 'Скасувати',
|
||||
back: 'Назад',
|
||||
import_from_file: 'Імпортувати з файлу',
|
||||
empty_diagram: 'Порожня діаграма',
|
||||
empty_diagram: 'Порожня база даних',
|
||||
continue: 'Продовжити',
|
||||
import: 'Імпорт',
|
||||
},
|
||||
@@ -481,6 +510,7 @@ export const uk: LanguageTranslation = {
|
||||
new_relationship: 'Новий звʼязок',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'Нова Нотатка',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const vi: LanguageTranslation = {
|
||||
browse: 'Duyệt',
|
||||
tables: 'Bảng',
|
||||
refs: 'Refs',
|
||||
areas: 'Khu vực',
|
||||
dependencies: 'Phụ thuộc',
|
||||
custom_types: 'Kiểu tùy chỉnh',
|
||||
visuals: 'Hình ảnh',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -232,6 +232,35 @@ export const vi: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: 'Hình ảnh',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: 'Ghi chú',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: 'Lọc',
|
||||
add_note: 'Thêm Ghi Chú',
|
||||
no_results: 'Không tìm thấy ghi chú',
|
||||
clear: 'Xóa Bộ Lọc',
|
||||
empty_state: {
|
||||
title: 'Không Có Ghi Chú',
|
||||
description:
|
||||
'Tạo ghi chú để thêm chú thích văn bản trên canvas',
|
||||
},
|
||||
note: {
|
||||
empty_note: 'Ghi chú trống',
|
||||
note_actions: {
|
||||
title: 'Hành Động Ghi Chú',
|
||||
edit_content: 'Chỉnh Sửa Nội Dung',
|
||||
delete_note: 'Xóa Ghi Chú',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -309,7 +338,7 @@ export const vi: LanguageTranslation = {
|
||||
cancel: 'Hủy',
|
||||
import_from_file: 'Nhập từ tệp',
|
||||
back: 'Trở lại',
|
||||
empty_diagram: 'Sơ đồ trống',
|
||||
empty_diagram: 'Cơ sở dữ liệu trống',
|
||||
continue: 'Tiếp tục',
|
||||
import: 'Nhập',
|
||||
},
|
||||
@@ -482,6 +511,7 @@ export const vi: LanguageTranslation = {
|
||||
new_relationship: 'Tạo quan hệ mới',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: 'Ghi Chú Mới',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const zh_CN: LanguageTranslation = {
|
||||
browse: '浏览',
|
||||
tables: '表',
|
||||
refs: '引用',
|
||||
areas: '区域',
|
||||
dependencies: '依赖关系',
|
||||
custom_types: '自定义类型',
|
||||
visuals: '视觉效果',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -229,6 +229,34 @@ export const zh_CN: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: '视觉效果',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: '笔记',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: '筛选',
|
||||
add_note: '添加笔记',
|
||||
no_results: '未找到笔记',
|
||||
clear: '清除筛选',
|
||||
empty_state: {
|
||||
title: '没有笔记',
|
||||
description: '创建笔记以在画布上添加文本注释',
|
||||
},
|
||||
note: {
|
||||
empty_note: '空笔记',
|
||||
note_actions: {
|
||||
title: '笔记操作',
|
||||
edit_content: '编辑内容',
|
||||
delete_note: '删除笔记',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -306,7 +334,7 @@ export const zh_CN: LanguageTranslation = {
|
||||
cancel: '取消',
|
||||
import_from_file: '从文件导入',
|
||||
back: '上一步',
|
||||
empty_diagram: '新建空关系图',
|
||||
empty_diagram: '空数据库',
|
||||
continue: '下一步',
|
||||
import: '导入',
|
||||
},
|
||||
@@ -477,6 +505,7 @@ export const zh_CN: LanguageTranslation = {
|
||||
new_relationship: '新建关系',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: '新笔记',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -7,9 +7,9 @@ export const zh_TW: LanguageTranslation = {
|
||||
browse: '瀏覽',
|
||||
tables: '表格',
|
||||
refs: 'Refs',
|
||||
areas: '區域',
|
||||
dependencies: '相依性',
|
||||
custom_types: '自定義類型',
|
||||
visuals: '視覺效果',
|
||||
},
|
||||
menu: {
|
||||
actions: {
|
||||
@@ -229,6 +229,34 @@ export const zh_TW: LanguageTranslation = {
|
||||
description: 'Create an area to get started',
|
||||
},
|
||||
},
|
||||
|
||||
visuals_section: {
|
||||
visuals: '視覺效果',
|
||||
tabs: {
|
||||
areas: 'Areas',
|
||||
notes: '筆記',
|
||||
},
|
||||
},
|
||||
|
||||
notes_section: {
|
||||
filter: '篩選',
|
||||
add_note: '新增筆記',
|
||||
no_results: '未找到筆記',
|
||||
clear: '清除篩選',
|
||||
empty_state: {
|
||||
title: '沒有筆記',
|
||||
description: '建立筆記以在畫布上新增文字註解',
|
||||
},
|
||||
note: {
|
||||
empty_note: '空白筆記',
|
||||
note_actions: {
|
||||
title: '筆記操作',
|
||||
edit_content: '編輯內容',
|
||||
delete_note: '刪除筆記',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: Translate
|
||||
custom_types_section: {
|
||||
custom_types: 'Custom Types',
|
||||
@@ -305,7 +333,7 @@ export const zh_TW: LanguageTranslation = {
|
||||
cancel: '取消',
|
||||
import_from_file: '從檔案匯入',
|
||||
back: '返回',
|
||||
empty_diagram: '空白圖表',
|
||||
empty_diagram: '空資料庫',
|
||||
continue: '繼續',
|
||||
import: '匯入',
|
||||
},
|
||||
@@ -477,6 +505,7 @@ export const zh_TW: LanguageTranslation = {
|
||||
new_relationship: '新建關聯',
|
||||
// TODO: Translate
|
||||
new_area: 'New Area',
|
||||
new_note: '新筆記',
|
||||
},
|
||||
|
||||
table_node_context_menu: {
|
||||
|
||||
@@ -18,4 +18,7 @@
|
||||
|
||||
.marker-definitions {
|
||||
}
|
||||
|
||||
.nodrag {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { DBIndex } from './domain/db-index';
|
||||
import type { DBRelationship } from './domain/db-relationship';
|
||||
import type { DBTable } from './domain/db-table';
|
||||
import type { Diagram } from './domain/diagram';
|
||||
import type { Note } from './domain/note';
|
||||
import { generateId as defaultGenerateId } from './utils';
|
||||
|
||||
const generateIdsMapFromTable = (
|
||||
@@ -49,6 +50,10 @@ const generateIdsMapFromDiagram = (
|
||||
idsMap.set(area.id, generateId());
|
||||
});
|
||||
|
||||
diagram.notes?.forEach((note) => {
|
||||
idsMap.set(note.id, generateId());
|
||||
});
|
||||
|
||||
diagram.customTypes?.forEach((customType) => {
|
||||
idsMap.set(customType.id, generateId());
|
||||
});
|
||||
@@ -218,6 +223,21 @@ export const cloneDiagram = (
|
||||
})
|
||||
.filter((area): area is Area => area !== null) ?? [];
|
||||
|
||||
const notes: Note[] =
|
||||
diagram.notes
|
||||
?.map((note) => {
|
||||
const id = getNewId(note.id);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...note,
|
||||
id,
|
||||
} satisfies Note;
|
||||
})
|
||||
.filter((note): note is Note => note !== null) ?? [];
|
||||
|
||||
const customTypes: DBCustomType[] =
|
||||
diagram.customTypes
|
||||
?.map((customType) => {
|
||||
@@ -242,6 +262,7 @@ export const cloneDiagram = (
|
||||
relationships,
|
||||
tables,
|
||||
areas,
|
||||
notes,
|
||||
customTypes,
|
||||
createdAt: diagram.createdAt
|
||||
? new Date(diagram.createdAt)
|
||||
|
||||
@@ -129,9 +129,6 @@ export const clickhouseDataTypes: readonly DataTypeData[] = [
|
||||
{ name: 'enum', id: 'enum' },
|
||||
{ name: 'lowcardinality', id: 'lowcardinality' },
|
||||
|
||||
// Array Type
|
||||
{ name: 'array', id: 'array' },
|
||||
|
||||
// Tuple Type
|
||||
{ name: 'tuple', id: 'tuple' },
|
||||
{ name: 'map', id: 'map' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { DatabaseType } from '../../domain/database-type';
|
||||
import { databaseSupportsArrays } from '../../domain/database-capabilities';
|
||||
import { clickhouseDataTypes } from './clickhouse-data-types';
|
||||
import { genericDataTypes } from './generic-data-types';
|
||||
import { mariadbDataTypes } from './mariadb-data-types';
|
||||
@@ -165,3 +166,34 @@ export const supportsAutoIncrementDataType = (
|
||||
'decimal',
|
||||
].includes(dataTypeName.toLocaleLowerCase());
|
||||
};
|
||||
|
||||
export const autoIncrementAlwaysOn = (dataTypeName: string): boolean => {
|
||||
return ['serial', 'bigserial', 'smallserial'].includes(
|
||||
dataTypeName.toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
export const requiresNotNull = (dataTypeName: string): boolean => {
|
||||
return ['serial', 'bigserial', 'smallserial'].includes(
|
||||
dataTypeName.toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
const ARRAY_INCOMPATIBLE_TYPES = [
|
||||
'serial',
|
||||
'bigserial',
|
||||
'smallserial',
|
||||
] as const;
|
||||
|
||||
export const supportsArrayDataType = (
|
||||
dataTypeName: string,
|
||||
databaseType: DatabaseType
|
||||
): boolean => {
|
||||
if (!databaseSupportsArrays(databaseType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !ARRAY_INCOMPATIBLE_TYPES.includes(
|
||||
dataTypeName.toLowerCase() as (typeof ARRAY_INCOMPATIBLE_TYPES)[number]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export const postgresDataTypes: readonly DataTypeData[] = [
|
||||
{ name: 'text', id: 'text', usageLevel: 1 },
|
||||
{ name: 'boolean', id: 'boolean', usageLevel: 1 },
|
||||
{ name: 'timestamp', id: 'timestamp', usageLevel: 1 },
|
||||
{ name: 'timestamptz', id: 'timestamptz', usageLevel: 1 },
|
||||
{ name: 'date', id: 'date', usageLevel: 1 },
|
||||
|
||||
// Level 2 - Second most common types
|
||||
@@ -42,6 +43,7 @@ export const postgresDataTypes: readonly DataTypeData[] = [
|
||||
id: 'timestamp_with_time_zone',
|
||||
usageLevel: 2,
|
||||
},
|
||||
{ name: 'int', id: 'int', usageLevel: 2 },
|
||||
|
||||
// Less common types
|
||||
{
|
||||
@@ -95,7 +97,6 @@ export const postgresDataTypes: readonly DataTypeData[] = [
|
||||
{ name: 'tsvector', id: 'tsvector' },
|
||||
{ name: 'tsquery', id: 'tsquery' },
|
||||
{ name: 'xml', id: 'xml' },
|
||||
{ name: 'array', id: 'array' },
|
||||
{ name: 'int4range', id: 'int4range' },
|
||||
{ name: 'int8range', id: 'int8range' },
|
||||
{ name: 'numrange', id: 'numrange' },
|
||||
|
||||
@@ -57,6 +57,10 @@ export const createFieldsFromMetadata = ({
|
||||
...(col.precision?.scale ? { scale: col.precision.scale } : {}),
|
||||
...(col.default ? { default: col.default } : {}),
|
||||
...(col.collation ? { collation: col.collation } : {}),
|
||||
...(col.is_identity !== undefined
|
||||
? { increment: col.is_identity }
|
||||
: {}),
|
||||
...(col.is_array !== undefined ? { isArray: col.is_array } : {}),
|
||||
createdAt: Date.now(),
|
||||
comments: col.comment ? col.comment : undefined,
|
||||
})
|
||||
|
||||
@@ -64,7 +64,7 @@ export const loadFromDatabaseMetadata = async ({
|
||||
const diagram: Diagram = {
|
||||
id: generateDiagramId(),
|
||||
name: databaseMetadata.database_name
|
||||
? `${databaseMetadata.database_name}-db`
|
||||
? `${databaseMetadata.database_name}`
|
||||
: diagramNumber
|
||||
? `Diagram ${diagramNumber}`
|
||||
: 'New Diagram',
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface ColumnInfo {
|
||||
default?: string | null; // Default value for the column, nullable
|
||||
collation?: string | null;
|
||||
comment?: string | null;
|
||||
is_identity?: boolean | null; // Indicates if the column is auto-increment/identity
|
||||
is_array?: boolean | null; // Indicates if the column is an array type
|
||||
}
|
||||
|
||||
export const ColumnInfoSchema: z.ZodType<ColumnInfo> = z.object({
|
||||
@@ -35,4 +37,6 @@ export const ColumnInfoSchema: z.ZodType<ColumnInfo> = z.object({
|
||||
default: z.string().nullable().optional(),
|
||||
collation: z.string().nullable().optional(),
|
||||
comment: z.string().nullable().optional(),
|
||||
is_identity: z.boolean().nullable().optional(),
|
||||
is_array: z.boolean().nullable().optional(),
|
||||
});
|
||||
|
||||
@@ -127,7 +127,13 @@ cols AS (
|
||||
',"default":"', null,
|
||||
'","collation":"', COALESCE(cols.COLLATION_NAME::TEXT, ''),
|
||||
'","comment":"', COALESCE(replace(replace(dsc.description::TEXT, '"', '\\"'), '\\x', '\\\\x'), ''),
|
||||
'"}')), ',') AS cols_metadata
|
||||
'","is_identity":', CASE
|
||||
WHEN cols.is_identity = 'YES' THEN 'true'
|
||||
WHEN cols.column_default IS NOT NULL AND cols.column_default LIKE 'nextval(%' THEN 'true'
|
||||
WHEN cols.column_default LIKE 'unique_rowid()%' THEN 'true'
|
||||
ELSE 'false'
|
||||
END,
|
||||
'}')), ',') AS cols_metadata
|
||||
FROM information_schema.columns cols
|
||||
LEFT JOIN pg_catalog.pg_class c
|
||||
ON c.relname = cols.table_name
|
||||
|
||||
@@ -69,7 +69,9 @@ SELECT CAST(CONCAT(
|
||||
',"ordinal_position":', cols.ordinal_position,
|
||||
',"nullable":', IF(cols.is_nullable = 'YES', 'true', 'false'),
|
||||
',"default":"', ${withExtras ? withDefault : withoutDefault},
|
||||
'","collation":"', IFNULL(cols.collation_name, ''), '"}')
|
||||
'","collation":"', IFNULL(cols.collation_name, ''),
|
||||
'","is_identity":', IF(cols.extra LIKE '%auto_increment%', 'true', 'false'),
|
||||
'"}')
|
||||
) FROM (
|
||||
SELECT cols.table_schema,
|
||||
cols.table_name,
|
||||
@@ -81,7 +83,8 @@ SELECT CAST(CONCAT(
|
||||
cols.ordinal_position,
|
||||
cols.is_nullable,
|
||||
cols.column_default,
|
||||
cols.collation_name
|
||||
cols.collation_name,
|
||||
cols.extra
|
||||
FROM information_schema.columns cols
|
||||
WHERE cols.table_schema = DATABASE()
|
||||
) AS cols), ''),
|
||||
|
||||
@@ -92,7 +92,9 @@ export const getMySQLQuery = (
|
||||
',"ordinal_position":', cols.ordinal_position,
|
||||
',"nullable":', IF(cols.is_nullable = 'YES', 'true', 'false'),
|
||||
',"default":"', ${withExtras ? withDefault : withoutDefault},
|
||||
'","collation":"', IFNULL(cols.collation_name, ''), '"}'
|
||||
'","collation":"', IFNULL(cols.collation_name, ''),
|
||||
'","is_identity":', IF(cols.extra LIKE '%auto_increment%', 'true', 'false'),
|
||||
'}'
|
||||
)))))
|
||||
), indexes as (
|
||||
(SELECT (@indexes:=NULL),
|
||||
|
||||
@@ -181,7 +181,13 @@ cols AS (
|
||||
'","table":"', cols.table_name,
|
||||
'","name":"', cols.column_name,
|
||||
'","ordinal_position":', cols.ordinal_position,
|
||||
',"type":"', case when LOWER(replace(cols.data_type, '"', '')) = 'user-defined' then pg_type.typname else LOWER(replace(cols.data_type, '"', '')) end,
|
||||
',"type":"', CASE WHEN cols.data_type = 'ARRAY' THEN
|
||||
format_type(pg_type.typelem, NULL)
|
||||
WHEN LOWER(replace(cols.data_type, '"', '')) = 'user-defined' THEN
|
||||
format_type(pg_type.oid, NULL)
|
||||
ELSE
|
||||
LOWER(replace(cols.data_type, '"', ''))
|
||||
END,
|
||||
'","character_maximum_length":"', COALESCE(cols.character_maximum_length::text, 'null'),
|
||||
'","precision":',
|
||||
CASE
|
||||
@@ -194,7 +200,16 @@ cols AS (
|
||||
',"default":"', ${withExtras ? withDefault : withoutDefault},
|
||||
'","collation":"', COALESCE(cols.COLLATION_NAME, ''),
|
||||
'","comment":"', ${withExtras ? withComments : withoutComments},
|
||||
'"}')), ',') AS cols_metadata
|
||||
'","is_identity":', CASE
|
||||
WHEN cols.is_identity = 'YES' THEN 'true'
|
||||
WHEN cols.column_default IS NOT NULL AND cols.column_default LIKE 'nextval(%' THEN 'true'
|
||||
ELSE 'false'
|
||||
END,
|
||||
',"is_array":', CASE
|
||||
WHEN cols.data_type = 'ARRAY' OR pg_type.typelem > 0 THEN 'true'
|
||||
ELSE 'false'
|
||||
END,
|
||||
'}')), ',') AS cols_metadata
|
||||
FROM information_schema.columns cols
|
||||
LEFT JOIN pg_catalog.pg_class c
|
||||
ON c.relname = cols.table_name
|
||||
@@ -206,6 +221,8 @@ cols AS (
|
||||
ON attr.attrelid = c.oid AND attr.attname = cols.column_name
|
||||
LEFT JOIN pg_catalog.pg_type
|
||||
ON pg_type.oid = attr.atttypid
|
||||
LEFT JOIN pg_catalog.pg_type AS elem_type
|
||||
ON elem_type.oid = pg_type.typelem
|
||||
WHERE cols.table_schema NOT IN ('information_schema', 'pg_catalog')${
|
||||
databaseEdition === DatabaseEdition.POSTGRESQL_TIMESCALE
|
||||
? timescaleColFilter
|
||||
|
||||
@@ -119,7 +119,13 @@ WITH fk_info AS (
|
||||
END
|
||||
ELSE null
|
||||
END,
|
||||
'default', ${withExtras ? withDefault : withoutDefault}
|
||||
'default', ${withExtras ? withDefault : withoutDefault},
|
||||
'is_identity',
|
||||
CASE
|
||||
WHEN p.pk = 1 AND LOWER(p.type) LIKE '%int%' THEN json('true')
|
||||
WHEN LOWER((SELECT sql FROM sqlite_master WHERE name = m.name)) LIKE '%' || p.name || '%autoincrement%' THEN json('true')
|
||||
ELSE json('false')
|
||||
END
|
||||
)
|
||||
) AS cols_metadata
|
||||
FROM
|
||||
@@ -292,7 +298,13 @@ WITH fk_info AS (
|
||||
END
|
||||
ELSE null
|
||||
END,
|
||||
'default', ${withExtras ? withDefault : withoutDefault}
|
||||
'default', ${withExtras ? withDefault : withoutDefault},
|
||||
'is_identity',
|
||||
CASE
|
||||
WHEN p.pk = 1 AND LOWER(p.type) LIKE '%int%' THEN json('true')
|
||||
WHEN LOWER((SELECT sql FROM sqlite_master WHERE name = m.name)) LIKE '%' || p.name || '%autoincrement%' THEN json('true')
|
||||
ELSE json('false')
|
||||
END
|
||||
)
|
||||
) AS cols_metadata
|
||||
FROM
|
||||
|
||||
@@ -91,6 +91,11 @@ cols AS (
|
||||
WHEN cols.COLLATION_NAME IS NULL THEN 'null'
|
||||
ELSE '"' + STRING_ESCAPE(cols.COLLATION_NAME, 'json') + '"'
|
||||
END +
|
||||
', "is_identity": ' + CASE
|
||||
WHEN COLUMNPROPERTY(OBJECT_ID(cols.TABLE_SCHEMA + '.' + cols.TABLE_NAME), cols.COLUMN_NAME, 'IsIdentity') = 1
|
||||
THEN 'true'
|
||||
ELSE 'false'
|
||||
END +
|
||||
N'}') COLLATE DATABASE_DEFAULT
|
||||
), N','
|
||||
) +
|
||||
|
||||
356
src/lib/data/sql-export/__tests__/array-fields.test.ts
Normal file
356
src/lib/data/sql-export/__tests__/array-fields.test.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateId } from '@/lib/utils';
|
||||
import { exportBaseSQL } from '../export-sql-script';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
import type { Diagram } from '@/lib/domain/diagram';
|
||||
|
||||
describe('SQL Export - Array Fields (Fantasy RPG Theme)', () => {
|
||||
it('should export array fields for magical spell components', () => {
|
||||
const diagram: Diagram = {
|
||||
id: 'test-diagram',
|
||||
name: 'Magical Spell System',
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
tables: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'spells',
|
||||
schema: '',
|
||||
fields: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'id',
|
||||
type: { id: 'uuid', name: 'uuid' },
|
||||
primaryKey: true,
|
||||
unique: true,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'name',
|
||||
type: { id: 'varchar', name: 'varchar' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
characterMaximumLength: '200',
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'components',
|
||||
type: { id: 'text', name: 'text' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
isArray: true,
|
||||
comments: 'Magical components needed for the spell',
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'elemental_types',
|
||||
type: { id: 'varchar', name: 'varchar' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
characterMaximumLength: '50',
|
||||
isArray: true,
|
||||
comments:
|
||||
'Elements involved: fire, water, earth, air',
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
x: 0,
|
||||
y: 0,
|
||||
color: '#3b82f6',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const sql = exportBaseSQL({
|
||||
diagram,
|
||||
targetDatabaseType: DatabaseType.POSTGRESQL,
|
||||
isDBMLFlow: true,
|
||||
});
|
||||
|
||||
expect(sql).toContain('CREATE TABLE "spells"');
|
||||
expect(sql).toContain('"components" text[]');
|
||||
expect(sql).toContain('"elemental_types" varchar(50)[]');
|
||||
});
|
||||
|
||||
it('should export array fields for hero inventory system', () => {
|
||||
const diagram: Diagram = {
|
||||
id: 'test-diagram',
|
||||
name: 'RPG Inventory System',
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
tables: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'heroes',
|
||||
schema: 'game',
|
||||
fields: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'id',
|
||||
type: { id: 'bigint', name: 'bigint' },
|
||||
primaryKey: true,
|
||||
unique: true,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'name',
|
||||
type: { id: 'varchar', name: 'varchar' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
characterMaximumLength: '100',
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'abilities',
|
||||
type: { id: 'varchar', name: 'varchar' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
characterMaximumLength: '100',
|
||||
isArray: true,
|
||||
comments:
|
||||
'Special abilities like Stealth, Fireball, etc',
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'inventory_slots',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
isArray: true,
|
||||
comments: 'Item IDs in inventory',
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'skill_levels',
|
||||
type: { id: 'decimal', name: 'decimal' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
isArray: true,
|
||||
comments: 'Skill proficiency levels',
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
x: 0,
|
||||
y: 0,
|
||||
color: '#ef4444',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const sql = exportBaseSQL({
|
||||
diagram,
|
||||
targetDatabaseType: DatabaseType.POSTGRESQL,
|
||||
isDBMLFlow: true,
|
||||
});
|
||||
|
||||
expect(sql).toContain('CREATE TABLE "game"."heroes"');
|
||||
expect(sql).toContain('"abilities" varchar(100)[]');
|
||||
expect(sql).toContain('"inventory_slots" integer[]');
|
||||
expect(sql).toContain('"skill_levels" decimal(5, 2)[]');
|
||||
});
|
||||
|
||||
it('should export non-array fields normally when isArray is false or undefined', () => {
|
||||
const diagram: Diagram = {
|
||||
id: 'test-diagram',
|
||||
name: 'Quest System',
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
tables: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'quests',
|
||||
schema: '',
|
||||
fields: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'id',
|
||||
type: { id: 'uuid', name: 'uuid' },
|
||||
primaryKey: true,
|
||||
unique: true,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'title',
|
||||
type: { id: 'varchar', name: 'varchar' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
characterMaximumLength: '200',
|
||||
isArray: false,
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'description',
|
||||
type: { id: 'text', name: 'text' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
// isArray is undefined - should not be treated as array
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
x: 0,
|
||||
y: 0,
|
||||
color: '#8b5cf6',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const sql = exportBaseSQL({
|
||||
diagram,
|
||||
targetDatabaseType: DatabaseType.POSTGRESQL,
|
||||
isDBMLFlow: true,
|
||||
});
|
||||
|
||||
expect(sql).toContain('"title" varchar(200)');
|
||||
expect(sql).not.toContain('"title" varchar(200)[]');
|
||||
expect(sql).toContain('"description" text');
|
||||
expect(sql).not.toContain('"description" text[]');
|
||||
});
|
||||
|
||||
it('should handle mixed array and non-array fields in magical creatures table', () => {
|
||||
const diagram: Diagram = {
|
||||
id: 'test-diagram',
|
||||
name: 'Bestiary System',
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
tables: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'magical_creatures',
|
||||
schema: 'bestiary',
|
||||
fields: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'id',
|
||||
type: { id: 'bigint', name: 'bigint' },
|
||||
primaryKey: true,
|
||||
unique: true,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'species_name',
|
||||
type: { id: 'varchar', name: 'varchar' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
characterMaximumLength: '100',
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'habitats',
|
||||
type: { id: 'varchar', name: 'varchar' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
characterMaximumLength: '80',
|
||||
isArray: true,
|
||||
comments:
|
||||
'Preferred habitats: forest, mountain, swamp',
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'danger_level',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
default: '1',
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'resistances',
|
||||
type: { id: 'varchar', name: 'varchar' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
characterMaximumLength: '50',
|
||||
isArray: true,
|
||||
comments: 'Damage resistances',
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'is_tameable',
|
||||
type: { id: 'boolean', name: 'boolean' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
default: 'false',
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
x: 0,
|
||||
y: 0,
|
||||
color: '#10b981',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const sql = exportBaseSQL({
|
||||
diagram,
|
||||
targetDatabaseType: DatabaseType.POSTGRESQL,
|
||||
isDBMLFlow: true,
|
||||
});
|
||||
|
||||
expect(sql).toContain('CREATE TABLE "bestiary"."magical_creatures"');
|
||||
expect(sql).toContain('"species_name" varchar(100)');
|
||||
expect(sql).not.toContain('"species_name" varchar(100)[]');
|
||||
expect(sql).toContain('"habitats" varchar(80)[]');
|
||||
expect(sql).toContain('"danger_level" integer');
|
||||
expect(sql).not.toContain('"danger_level" integer[]');
|
||||
expect(sql).toContain('"resistances" varchar(50)[]');
|
||||
expect(sql).toContain('"is_tameable" boolean');
|
||||
expect(sql).not.toContain('"is_tameable" boolean[]');
|
||||
});
|
||||
});
|
||||
@@ -286,10 +286,14 @@ export function exportPostgreSQL({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle array types (check if the type name ends with '[]')
|
||||
if (typeName.endsWith('[]')) {
|
||||
typeWithSize =
|
||||
typeWithSize.replace('[]', '') + '[]';
|
||||
// Handle array types (check if isArray flag or if type name ends with '[]')
|
||||
if (field.isArray || typeName.endsWith('[]')) {
|
||||
// Remove any existing [] notation
|
||||
const baseTypeWithoutArray = typeWithSize.replace(
|
||||
/\[\]$/,
|
||||
''
|
||||
);
|
||||
typeWithSize = baseTypeWithoutArray + '[]';
|
||||
}
|
||||
|
||||
const notNull = field.nullable ? '' : ' NOT NULL';
|
||||
|
||||
@@ -1,17 +1,67 @@
|
||||
import type { Diagram } from '../../domain/diagram';
|
||||
import { OPENAI_API_KEY, OPENAI_API_ENDPOINT, LLM_MODEL_NAME } from '@/lib/env';
|
||||
import {
|
||||
DatabaseType,
|
||||
databaseTypesWithCommentSupport,
|
||||
} from '@/lib/domain/database-type';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
import type { DBTable } from '@/lib/domain/db-table';
|
||||
import type { DataType } from '../data-types/data-types';
|
||||
import { dataTypeMap, type DataType } from '../data-types/data-types';
|
||||
import { generateCacheKey, getFromCache, setInCache } from './export-sql-cache';
|
||||
import { exportMSSQL } from './export-per-type/mssql';
|
||||
import { exportPostgreSQL } from './export-per-type/postgresql';
|
||||
import { exportSQLite } from './export-per-type/sqlite';
|
||||
import { exportMySQL } from './export-per-type/mysql';
|
||||
import { escapeSQLComment } from './export-per-type/common';
|
||||
import {
|
||||
databaseTypesWithCommentSupport,
|
||||
supportsCustomTypes,
|
||||
} from '@/lib/domain/database-capabilities';
|
||||
|
||||
// Function to format default values with proper quoting
|
||||
const formatDefaultValue = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
|
||||
// SQL keywords and function-like keywords that don't need quotes
|
||||
const keywords = [
|
||||
'TRUE',
|
||||
'FALSE',
|
||||
'NULL',
|
||||
'CURRENT_TIMESTAMP',
|
||||
'CURRENT_DATE',
|
||||
'CURRENT_TIME',
|
||||
'NOW',
|
||||
'GETDATE',
|
||||
'NEWID',
|
||||
'UUID',
|
||||
];
|
||||
if (keywords.includes(trimmed.toUpperCase())) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Function calls (contain parentheses) don't need quotes
|
||||
if (trimmed.includes('(') && trimmed.includes(')')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Numbers don't need quotes
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Already quoted strings - keep as is
|
||||
if (
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
||||
) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Check if it's a simple identifier (alphanumeric, no spaces) that might be a currency or enum
|
||||
// These typically don't have spaces and are short (< 10 chars)
|
||||
if (/^[A-Z][A-Z0-9_]*$/i.test(trimmed) && trimmed.length <= 10) {
|
||||
return trimmed; // Treat as unquoted identifier (e.g., EUR, USD)
|
||||
}
|
||||
|
||||
// Everything else needs to be quoted and escaped
|
||||
return `'${trimmed.replace(/'/g, "''")}'`;
|
||||
};
|
||||
|
||||
// Function to simplify verbose data type names
|
||||
const simplifyDataType = (typeName: string): string => {
|
||||
@@ -151,10 +201,7 @@ export const exportBaseSQL = ({
|
||||
// or if we rely on the DBML generator to create Enums separately (as currently done)
|
||||
// For now, let's assume PostgreSQL-style for demonstration if isDBMLFlow is false.
|
||||
// If isDBMLFlow is true, we let TableDBML.tsx handle Enum syntax directly.
|
||||
if (
|
||||
targetDatabaseType === DatabaseType.POSTGRESQL &&
|
||||
!isDBMLFlow
|
||||
) {
|
||||
if (supportsCustomTypes(targetDatabaseType) && !isDBMLFlow) {
|
||||
const enumValues = customType.values
|
||||
.map((v) => `'${v.replace(/'/g, "''")}'`)
|
||||
.join(', ');
|
||||
@@ -167,10 +214,7 @@ export const exportBaseSQL = ({
|
||||
) {
|
||||
// For PostgreSQL, generate CREATE TYPE ... AS (...)
|
||||
// This is crucial for composite types to be recognized by the DBML importer
|
||||
if (
|
||||
targetDatabaseType === DatabaseType.POSTGRESQL ||
|
||||
isDBMLFlow
|
||||
) {
|
||||
if (supportsCustomTypes(targetDatabaseType) || isDBMLFlow) {
|
||||
// Assume other DBs might not support this or DBML flow needs it
|
||||
const compositeFields = customType.fields
|
||||
.map((f) => `${f.field} ${simplifyDataType(f.type)}`)
|
||||
@@ -185,13 +229,12 @@ export const exportBaseSQL = ({
|
||||
(ct.kind === 'enum' &&
|
||||
ct.values &&
|
||||
ct.values.length > 0 &&
|
||||
targetDatabaseType === DatabaseType.POSTGRESQL &&
|
||||
supportsCustomTypes(targetDatabaseType) &&
|
||||
!isDBMLFlow) ||
|
||||
(ct.kind === 'composite' &&
|
||||
ct.fields &&
|
||||
ct.fields.length > 0 &&
|
||||
(targetDatabaseType === DatabaseType.POSTGRESQL ||
|
||||
isDBMLFlow))
|
||||
(supportsCustomTypes(targetDatabaseType) || isDBMLFlow))
|
||||
)
|
||||
) {
|
||||
sqlScript += '\n';
|
||||
@@ -251,7 +294,7 @@ export const exportBaseSQL = ({
|
||||
|
||||
if (
|
||||
customEnumType &&
|
||||
targetDatabaseType === DatabaseType.POSTGRESQL &&
|
||||
supportsCustomTypes(targetDatabaseType) &&
|
||||
!isDBMLFlow
|
||||
) {
|
||||
typeName = customEnumType.schema
|
||||
@@ -294,7 +337,14 @@ export const exportBaseSQL = ({
|
||||
}
|
||||
|
||||
const quotedFieldName = getQuotedFieldName(field.name, isDBMLFlow);
|
||||
sqlScript += ` ${quotedFieldName} ${typeName}`;
|
||||
|
||||
// Quote multi-word type names for DBML flow to prevent @dbml/core parser issues
|
||||
const quotedTypeName =
|
||||
isDBMLFlow && typeName.includes(' ')
|
||||
? `"${typeName}"`
|
||||
: typeName;
|
||||
|
||||
sqlScript += ` ${quotedFieldName} ${quotedTypeName}`;
|
||||
|
||||
// Add size for character types
|
||||
if (
|
||||
@@ -314,11 +364,31 @@ export const exportBaseSQL = ({
|
||||
sqlScript += `(1)`;
|
||||
}
|
||||
|
||||
// Add precision and scale for numeric types
|
||||
if (field.precision && field.scale) {
|
||||
sqlScript += `(${field.precision}, ${field.scale})`;
|
||||
} else if (field.precision) {
|
||||
sqlScript += `(${field.precision})`;
|
||||
// Add precision and scale for numeric types only
|
||||
const precisionAndScaleTypes = dataTypeMap[targetDatabaseType]
|
||||
.filter(
|
||||
(t) =>
|
||||
t.fieldAttributes?.precision && t.fieldAttributes?.scale
|
||||
)
|
||||
.map((t) => t.name);
|
||||
|
||||
const isNumericType = precisionAndScaleTypes.some(
|
||||
(t) =>
|
||||
field.type.name.toLowerCase().includes(t) ||
|
||||
typeName.toLowerCase().includes(t)
|
||||
);
|
||||
|
||||
if (isNumericType) {
|
||||
if (field.precision && field.scale) {
|
||||
sqlScript += `(${field.precision}, ${field.scale})`;
|
||||
} else if (field.precision) {
|
||||
sqlScript += `(${field.precision})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add array suffix if field is an array (after type size and precision)
|
||||
if (field.isArray) {
|
||||
sqlScript += '[]';
|
||||
}
|
||||
|
||||
// Handle NOT NULL constraint
|
||||
@@ -331,9 +401,26 @@ export const exportBaseSQL = ({
|
||||
sqlScript += ` UNIQUE`;
|
||||
}
|
||||
|
||||
// Handle AUTO INCREMENT - add as a comment for AI to process
|
||||
// Handle AUTO INCREMENT
|
||||
if (field.increment) {
|
||||
sqlScript += ` /* AUTO_INCREMENT */`;
|
||||
if (isDBMLFlow) {
|
||||
// For DBML flow, generate proper database-specific syntax
|
||||
if (
|
||||
targetDatabaseType === DatabaseType.MYSQL ||
|
||||
targetDatabaseType === DatabaseType.MARIADB
|
||||
) {
|
||||
sqlScript += ` AUTO_INCREMENT`;
|
||||
} else if (targetDatabaseType === DatabaseType.SQL_SERVER) {
|
||||
sqlScript += ` IDENTITY(1,1)`;
|
||||
} else if (targetDatabaseType === DatabaseType.SQLITE) {
|
||||
// SQLite AUTOINCREMENT only works with INTEGER PRIMARY KEY
|
||||
// Will be handled when PRIMARY KEY is added
|
||||
}
|
||||
// PostgreSQL/CockroachDB: increment attribute added by restoreIncrementAttribute in DBML export
|
||||
} else {
|
||||
// For non-DBML flow, add as a comment for AI to process
|
||||
sqlScript += ` /* AUTO_INCREMENT */`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle DEFAULT value
|
||||
@@ -366,7 +453,19 @@ export const exportBaseSQL = ({
|
||||
fieldDefault = `now()`;
|
||||
}
|
||||
|
||||
sqlScript += ` DEFAULT ${fieldDefault}`;
|
||||
// Fix CURRENT_DATE() for PostgreSQL in DBML flow - PostgreSQL uses CURRENT_DATE without parentheses
|
||||
if (
|
||||
isDBMLFlow &&
|
||||
targetDatabaseType === DatabaseType.POSTGRESQL
|
||||
) {
|
||||
if (fieldDefault.toUpperCase() === 'CURRENT_DATE()') {
|
||||
fieldDefault = 'CURRENT_DATE';
|
||||
}
|
||||
}
|
||||
|
||||
// Format default value with proper quoting
|
||||
const formattedDefault = formatDefaultValue(fieldDefault);
|
||||
sqlScript += ` DEFAULT ${formattedDefault}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +473,17 @@ export const exportBaseSQL = ({
|
||||
const pkIndex = table.indexes.find((idx) => idx.isPrimaryKey);
|
||||
if (field.primaryKey && !hasCompositePrimaryKey && !pkIndex?.name) {
|
||||
sqlScript += ' PRIMARY KEY';
|
||||
|
||||
// For SQLite with DBML flow, add AUTOINCREMENT after PRIMARY KEY
|
||||
if (
|
||||
isDBMLFlow &&
|
||||
field.increment &&
|
||||
targetDatabaseType === DatabaseType.SQLITE &&
|
||||
(typeName.toLowerCase() === 'integer' ||
|
||||
typeName.toLowerCase() === 'int')
|
||||
) {
|
||||
sqlScript += ' AUTOINCREMENT';
|
||||
}
|
||||
}
|
||||
|
||||
// Add a comma after each field except the last one (or before PK constraint)
|
||||
@@ -454,10 +564,16 @@ export const exportBaseSQL = ({
|
||||
.join(', ');
|
||||
|
||||
if (fieldNames) {
|
||||
const indexName =
|
||||
const rawIndexName =
|
||||
table.schema && !isDBMLFlow
|
||||
? `${table.schema}_${index.name}`
|
||||
: index.name;
|
||||
// Quote index name if it contains special characters
|
||||
// For DBML flow, also quote if contains special characters
|
||||
const needsQuoting = /[^a-zA-Z0-9_]/.test(rawIndexName);
|
||||
const indexName = needsQuoting
|
||||
? `"${rawIndexName}"`
|
||||
: rawIndexName;
|
||||
sqlScript += `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName} ON ${tableName} (${fieldNames});\n`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { defaultTableColor } from '@/lib/colors';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
import type { DBCustomType } from '@/lib/domain/db-custom-type';
|
||||
import { DBCustomTypeKind } from '@/lib/domain/db-custom-type';
|
||||
import { supportsCustomTypes } from '@/lib/domain/database-capabilities';
|
||||
|
||||
// Common interfaces for SQL entities
|
||||
export interface SQLColumn {
|
||||
@@ -663,7 +664,7 @@ export function convertToChartDBDiagram(
|
||||
// Ensure integer types are preserved
|
||||
mappedType = { id: 'integer', name: 'integer' };
|
||||
} else if (
|
||||
sourceDatabaseType === DatabaseType.POSTGRESQL &&
|
||||
supportsCustomTypes(sourceDatabaseType) &&
|
||||
parserResult.enums &&
|
||||
parserResult.enums.some(
|
||||
(e) => e.name.toLowerCase() === column.type.toLowerCase()
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, it } from 'vitest';
|
||||
|
||||
describe('node-sql-parser - CREATE TYPE handling', () => {
|
||||
it('should show exact parser error for CREATE TYPE', async () => {
|
||||
const { Parser } = await import('node-sql-parser');
|
||||
const parser = new Parser();
|
||||
const parserOpts = {
|
||||
database: 'PostgreSQL',
|
||||
};
|
||||
|
||||
console.log('\n=== Testing CREATE TYPE statement ===');
|
||||
const createTypeSQL = `CREATE TYPE spell_element AS ENUM ('fire', 'water', 'earth', 'air');`;
|
||||
|
||||
try {
|
||||
parser.astify(createTypeSQL, parserOpts);
|
||||
console.log('CREATE TYPE parsed successfully');
|
||||
} catch (error) {
|
||||
console.log('CREATE TYPE parse error:', (error as Error).message);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing CREATE EXTENSION statement ===');
|
||||
const createExtensionSQL = `CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`;
|
||||
|
||||
try {
|
||||
parser.astify(createExtensionSQL, parserOpts);
|
||||
console.log('CREATE EXTENSION parsed successfully');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'CREATE EXTENSION parse error:',
|
||||
(error as Error).message
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing CREATE TABLE with custom type ===');
|
||||
const createTableWithTypeSQL = `CREATE TABLE wizards (
|
||||
id UUID PRIMARY KEY,
|
||||
element spell_element DEFAULT 'fire'
|
||||
);`;
|
||||
|
||||
try {
|
||||
parser.astify(createTableWithTypeSQL, parserOpts);
|
||||
console.log('CREATE TABLE with custom type parsed successfully');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'CREATE TABLE with custom type parse error:',
|
||||
(error as Error).message
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing CREATE TABLE with standard types only ===');
|
||||
const createTableStandardSQL = `CREATE TABLE wizards (
|
||||
id UUID PRIMARY KEY,
|
||||
element VARCHAR(20) DEFAULT 'fire'
|
||||
);`;
|
||||
|
||||
try {
|
||||
parser.astify(createTableStandardSQL, parserOpts);
|
||||
console.log('CREATE TABLE with standard types parsed successfully');
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'CREATE TABLE with standard types parse error:',
|
||||
(error as Error).message
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fromSQLite } from '../sqlite';
|
||||
|
||||
describe('SQLite Import Tests', () => {
|
||||
it('should parse SQLite script with sqlite_sequence table and all relationships', async () => {
|
||||
const sql = `
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
age INTEGER
|
||||
);
|
||||
CREATE TABLE sqlite_sequence(name,seq);
|
||||
CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
price REAL
|
||||
);
|
||||
CREATE TABLE user_products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
product_id INTEGER NOT NULL,
|
||||
purchased_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||
);
|
||||
`;
|
||||
|
||||
const result = await fromSQLite(sql);
|
||||
|
||||
// ============= CHECK TOTAL COUNTS =============
|
||||
// Should have exactly 4 tables
|
||||
expect(result.tables).toHaveLength(4);
|
||||
|
||||
// Should have exactly 2 foreign key relationships
|
||||
expect(result.relationships).toHaveLength(2);
|
||||
|
||||
// ============= CHECK USERS TABLE =============
|
||||
const usersTable = result.tables.find((t) => t.name === 'users');
|
||||
expect(usersTable).toBeDefined();
|
||||
expect(usersTable?.columns).toHaveLength(3); // id, name, age
|
||||
|
||||
// Check each column in users table
|
||||
expect(usersTable?.columns[0]).toMatchObject({
|
||||
name: 'id',
|
||||
type: 'INTEGER',
|
||||
primaryKey: true,
|
||||
increment: true,
|
||||
nullable: false,
|
||||
});
|
||||
expect(usersTable?.columns[1]).toMatchObject({
|
||||
name: 'name',
|
||||
type: 'TEXT',
|
||||
primaryKey: false,
|
||||
nullable: true,
|
||||
});
|
||||
expect(usersTable?.columns[2]).toMatchObject({
|
||||
name: 'age',
|
||||
type: 'INTEGER',
|
||||
primaryKey: false,
|
||||
nullable: true,
|
||||
});
|
||||
|
||||
// ============= CHECK SQLITE_SEQUENCE TABLE =============
|
||||
const sqliteSequenceTable = result.tables.find(
|
||||
(t) => t.name === 'sqlite_sequence'
|
||||
);
|
||||
expect(sqliteSequenceTable).toBeDefined();
|
||||
expect(sqliteSequenceTable?.columns).toHaveLength(2); // name, seq
|
||||
|
||||
// Check columns in sqlite_sequence table
|
||||
expect(sqliteSequenceTable?.columns[0]).toMatchObject({
|
||||
name: 'name',
|
||||
type: 'TEXT', // Should default to TEXT when no type specified
|
||||
primaryKey: false,
|
||||
nullable: true,
|
||||
});
|
||||
expect(sqliteSequenceTable?.columns[1]).toMatchObject({
|
||||
name: 'seq',
|
||||
type: 'TEXT', // Should default to TEXT when no type specified
|
||||
primaryKey: false,
|
||||
nullable: true,
|
||||
});
|
||||
|
||||
// ============= CHECK PRODUCTS TABLE =============
|
||||
const productsTable = result.tables.find((t) => t.name === 'products');
|
||||
expect(productsTable).toBeDefined();
|
||||
expect(productsTable?.columns).toHaveLength(3); // id, name, price
|
||||
|
||||
// Check each column in products table
|
||||
expect(productsTable?.columns[0]).toMatchObject({
|
||||
name: 'id',
|
||||
type: 'INTEGER',
|
||||
primaryKey: true,
|
||||
increment: true,
|
||||
nullable: false,
|
||||
});
|
||||
expect(productsTable?.columns[1]).toMatchObject({
|
||||
name: 'name',
|
||||
type: 'TEXT',
|
||||
primaryKey: false,
|
||||
nullable: true,
|
||||
});
|
||||
expect(productsTable?.columns[2]).toMatchObject({
|
||||
name: 'price',
|
||||
type: 'REAL',
|
||||
primaryKey: false,
|
||||
nullable: true,
|
||||
});
|
||||
|
||||
// ============= CHECK USER_PRODUCTS TABLE =============
|
||||
const userProductsTable = result.tables.find(
|
||||
(t) => t.name === 'user_products'
|
||||
);
|
||||
expect(userProductsTable).toBeDefined();
|
||||
expect(userProductsTable?.columns).toHaveLength(4); // id, user_id, product_id, purchased_at
|
||||
|
||||
// Check each column in user_products table
|
||||
expect(userProductsTable?.columns[0]).toMatchObject({
|
||||
name: 'id',
|
||||
type: 'INTEGER',
|
||||
primaryKey: true,
|
||||
increment: true,
|
||||
nullable: false,
|
||||
});
|
||||
expect(userProductsTable?.columns[1]).toMatchObject({
|
||||
name: 'user_id',
|
||||
type: 'INTEGER',
|
||||
primaryKey: false,
|
||||
nullable: false, // NOT NULL constraint
|
||||
});
|
||||
expect(userProductsTable?.columns[2]).toMatchObject({
|
||||
name: 'product_id',
|
||||
type: 'INTEGER',
|
||||
primaryKey: false,
|
||||
nullable: false, // NOT NULL constraint
|
||||
});
|
||||
expect(userProductsTable?.columns[3]).toMatchObject({
|
||||
name: 'purchased_at',
|
||||
type: 'TIMESTAMP', // DATETIME should map to TIMESTAMP
|
||||
primaryKey: false,
|
||||
nullable: true,
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
});
|
||||
|
||||
// ============= CHECK FOREIGN KEY RELATIONSHIPS =============
|
||||
// FK 1: user_products.user_id -> users.id
|
||||
const userIdFK = result.relationships.find(
|
||||
(r) =>
|
||||
r.sourceTable === 'user_products' &&
|
||||
r.sourceColumn === 'user_id' &&
|
||||
r.targetTable === 'users' &&
|
||||
r.targetColumn === 'id'
|
||||
);
|
||||
expect(userIdFK).toBeDefined();
|
||||
expect(userIdFK).toMatchObject({
|
||||
sourceTable: 'user_products',
|
||||
sourceColumn: 'user_id',
|
||||
targetTable: 'users',
|
||||
targetColumn: 'id',
|
||||
});
|
||||
|
||||
// FK 2: user_products.product_id -> products.id
|
||||
const productIdFK = result.relationships.find(
|
||||
(r) =>
|
||||
r.sourceTable === 'user_products' &&
|
||||
r.sourceColumn === 'product_id' &&
|
||||
r.targetTable === 'products' &&
|
||||
r.targetColumn === 'id'
|
||||
);
|
||||
expect(productIdFK).toBeDefined();
|
||||
expect(productIdFK).toMatchObject({
|
||||
sourceTable: 'user_products',
|
||||
sourceColumn: 'product_id',
|
||||
targetTable: 'products',
|
||||
targetColumn: 'id',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,11 +32,11 @@ export async function fromSQLite(sqlContent: string): Promise<SQLParserResult> {
|
||||
const tableMap: Record<string, string> = {}; // Maps table name to its ID
|
||||
|
||||
try {
|
||||
// SPECIAL HANDLING: Direct line-by-line parser for SQLite DDL
|
||||
// This ensures we preserve the exact data types from the original DDL
|
||||
// SPECIAL HANDLING: Direct regex-based parser for SQLite DDL
|
||||
// This ensures we handle all SQLite-specific syntax including tables without types
|
||||
const directlyParsedTables = parseCreateTableStatements(sqlContent);
|
||||
|
||||
// Check if we successfully parsed tables directly
|
||||
// Always try direct parsing first as it's more reliable for SQLite
|
||||
if (directlyParsedTables.length > 0) {
|
||||
// Map the direct parsing results to the expected SQLParserResult format
|
||||
directlyParsedTables.forEach((table) => {
|
||||
@@ -56,8 +56,19 @@ export async function fromSQLite(sqlContent: string): Promise<SQLParserResult> {
|
||||
// Process foreign keys using the regex approach
|
||||
findForeignKeysUsingRegex(sqlContent, tableMap, relationships);
|
||||
|
||||
// Return the result
|
||||
return { tables, relationships };
|
||||
// Create placeholder tables for any missing referenced tables
|
||||
addPlaceholderTablesForFKReferences(
|
||||
tables,
|
||||
relationships,
|
||||
tableMap
|
||||
);
|
||||
|
||||
// Filter out any invalid relationships
|
||||
const validRelationships = relationships.filter((rel) => {
|
||||
return isValidForeignKeyRelationship(rel, tables);
|
||||
});
|
||||
|
||||
return { tables, relationships: validRelationships };
|
||||
}
|
||||
|
||||
// Preprocess SQL to handle SQLite quoted identifiers
|
||||
@@ -130,101 +141,182 @@ function parseCreateTableStatements(sqlContent: string): {
|
||||
columns: SQLColumn[];
|
||||
}[] = [];
|
||||
|
||||
// Split SQL content into lines
|
||||
const lines = sqlContent.split('\n');
|
||||
|
||||
let currentTable: { name: string; columns: SQLColumn[] } | null = null;
|
||||
let inCreateTable = false;
|
||||
|
||||
// Process each line
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!line || line.startsWith('--')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for CREATE TABLE statement
|
||||
if (line.toUpperCase().startsWith('CREATE TABLE')) {
|
||||
// Extract table name
|
||||
const tableNameMatch =
|
||||
/CREATE\s+TABLE\s+(?:if\s+not\s+exists\s+)?["'`]?(\w+)["'`]?/i.exec(
|
||||
line
|
||||
);
|
||||
if (tableNameMatch && tableNameMatch[1]) {
|
||||
inCreateTable = true;
|
||||
currentTable = {
|
||||
name: tableNameMatch[1],
|
||||
columns: [],
|
||||
};
|
||||
// Remove comments before processing
|
||||
const cleanedSQL = sqlContent
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const commentIndex = line.indexOf('--');
|
||||
if (commentIndex >= 0) {
|
||||
return line.substring(0, commentIndex);
|
||||
}
|
||||
}
|
||||
// Check for end of CREATE TABLE statement
|
||||
else if (inCreateTable && line.includes(');')) {
|
||||
if (currentTable) {
|
||||
tables.push(currentTable);
|
||||
return line;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// Match all CREATE TABLE statements including those without column definitions
|
||||
const createTableRegex =
|
||||
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?\s*\(([^;]+?)\)\s*;/gis;
|
||||
let match;
|
||||
|
||||
while ((match = createTableRegex.exec(cleanedSQL)) !== null) {
|
||||
const tableName = match[1];
|
||||
const tableBody = match[2].trim();
|
||||
|
||||
const table: { name: string; columns: SQLColumn[] } = {
|
||||
name: tableName,
|
||||
columns: [],
|
||||
};
|
||||
|
||||
// Special case: sqlite_sequence or tables with columns but no types
|
||||
if (tableName === 'sqlite_sequence' || !tableBody.includes(' ')) {
|
||||
// Parse simple column list without types (e.g., "name,seq")
|
||||
const simpleColumns = tableBody.split(',').map((col) => col.trim());
|
||||
for (const colName of simpleColumns) {
|
||||
if (
|
||||
colName &&
|
||||
!colName.toUpperCase().startsWith('FOREIGN KEY') &&
|
||||
!colName.toUpperCase().startsWith('PRIMARY KEY') &&
|
||||
!colName.toUpperCase().startsWith('UNIQUE') &&
|
||||
!colName.toUpperCase().startsWith('CHECK') &&
|
||||
!colName.toUpperCase().startsWith('CONSTRAINT')
|
||||
) {
|
||||
table.columns.push({
|
||||
name: colName.replace(/["'`]/g, ''),
|
||||
type: 'TEXT', // Default to TEXT for untyped columns
|
||||
nullable: true,
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
default: '',
|
||||
increment: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
inCreateTable = false;
|
||||
currentTable = null;
|
||||
}
|
||||
// Process column definitions inside CREATE TABLE
|
||||
else if (inCreateTable && currentTable && line.includes('"')) {
|
||||
// Column line pattern optimized for user's DDL format
|
||||
const columnPattern = /\s*["'`](\w+)["'`]\s+([A-Za-z0-9_]+)(.+)?/i;
|
||||
const match = columnPattern.exec(line);
|
||||
} else {
|
||||
// Parse normal table with typed columns
|
||||
// Split by commas not inside parentheses
|
||||
const columnDefs = [];
|
||||
let current = '';
|
||||
let parenDepth = 0;
|
||||
|
||||
if (match) {
|
||||
const columnName = match[1];
|
||||
const rawType = match[2].toUpperCase();
|
||||
const restOfLine = match[3] || '';
|
||||
for (let i = 0; i < tableBody.length; i++) {
|
||||
const char = tableBody[i];
|
||||
if (char === '(') parenDepth++;
|
||||
else if (char === ')') parenDepth--;
|
||||
else if (char === ',' && parenDepth === 0) {
|
||||
columnDefs.push(current.trim());
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
if (current.trim()) {
|
||||
columnDefs.push(current.trim());
|
||||
}
|
||||
|
||||
// Determine column properties
|
||||
const isPrimaryKey = restOfLine
|
||||
.toUpperCase()
|
||||
.includes('PRIMARY KEY');
|
||||
const isNotNull = restOfLine.toUpperCase().includes('NOT NULL');
|
||||
const isUnique = restOfLine.toUpperCase().includes('UNIQUE');
|
||||
for (const columnDef of columnDefs) {
|
||||
const line = columnDef.trim();
|
||||
|
||||
// Extract default value
|
||||
let defaultValue = '';
|
||||
const defaultMatch = /DEFAULT\s+([^,\s)]+)/i.exec(restOfLine);
|
||||
if (defaultMatch) {
|
||||
defaultValue = defaultMatch[1];
|
||||
// Skip constraints
|
||||
if (
|
||||
line.toUpperCase().startsWith('FOREIGN KEY') ||
|
||||
line.toUpperCase().startsWith('PRIMARY KEY') ||
|
||||
line.toUpperCase().startsWith('UNIQUE') ||
|
||||
line.toUpperCase().startsWith('CHECK') ||
|
||||
line.toUpperCase().startsWith('CONSTRAINT')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map to appropriate SQLite storage class
|
||||
let columnType = rawType;
|
||||
if (rawType === 'INTEGER' || rawType === 'INT') {
|
||||
columnType = 'INTEGER';
|
||||
} else if (
|
||||
['REAL', 'FLOAT', 'DOUBLE', 'NUMERIC', 'DECIMAL'].includes(
|
||||
rawType
|
||||
)
|
||||
) {
|
||||
columnType = 'REAL';
|
||||
} else if (rawType === 'BLOB' || rawType === 'BINARY') {
|
||||
columnType = 'BLOB';
|
||||
} else if (
|
||||
['TIMESTAMP', 'DATETIME', 'DATE'].includes(rawType)
|
||||
) {
|
||||
columnType = 'TIMESTAMP';
|
||||
} else {
|
||||
columnType = 'TEXT';
|
||||
}
|
||||
// Parse column: handle both quoted and unquoted identifiers
|
||||
// Pattern: [quotes]columnName[quotes] dataType [constraints]
|
||||
const columnPattern = /^["'`]?([\w]+)["'`]?\s+(\w+)(.*)$/i;
|
||||
const columnMatch = columnPattern.exec(line);
|
||||
|
||||
// Add column to the table
|
||||
currentTable.columns.push({
|
||||
name: columnName,
|
||||
type: columnType,
|
||||
nullable: !isNotNull,
|
||||
primaryKey: isPrimaryKey,
|
||||
unique: isUnique || isPrimaryKey,
|
||||
default: defaultValue,
|
||||
increment: isPrimaryKey && columnType === 'INTEGER',
|
||||
});
|
||||
if (columnMatch) {
|
||||
const columnName = columnMatch[1];
|
||||
const rawType = columnMatch[2].toUpperCase();
|
||||
const restOfLine = columnMatch[3] || '';
|
||||
const upperRest = restOfLine.toUpperCase();
|
||||
|
||||
// Determine column properties
|
||||
const isPrimaryKey = upperRest.includes('PRIMARY KEY');
|
||||
const isAutoIncrement = upperRest.includes('AUTOINCREMENT');
|
||||
const isNotNull =
|
||||
upperRest.includes('NOT NULL') || isPrimaryKey;
|
||||
const isUnique =
|
||||
upperRest.includes('UNIQUE') || isPrimaryKey;
|
||||
|
||||
// Extract default value
|
||||
let defaultValue = '';
|
||||
const defaultMatch = /DEFAULT\s+([^,)]+)/i.exec(restOfLine);
|
||||
if (defaultMatch) {
|
||||
defaultValue = defaultMatch[1].trim();
|
||||
// Remove quotes if present
|
||||
if (
|
||||
(defaultValue.startsWith("'") &&
|
||||
defaultValue.endsWith("'")) ||
|
||||
(defaultValue.startsWith('"') &&
|
||||
defaultValue.endsWith('"'))
|
||||
) {
|
||||
defaultValue = defaultValue.slice(1, -1);
|
||||
}
|
||||
}
|
||||
|
||||
// Map to appropriate SQLite storage class
|
||||
let columnType = rawType;
|
||||
if (rawType === 'INTEGER' || rawType === 'INT') {
|
||||
columnType = 'INTEGER';
|
||||
} else if (
|
||||
[
|
||||
'REAL',
|
||||
'FLOAT',
|
||||
'DOUBLE',
|
||||
'NUMERIC',
|
||||
'DECIMAL',
|
||||
].includes(rawType)
|
||||
) {
|
||||
columnType = 'REAL';
|
||||
} else if (rawType === 'BLOB' || rawType === 'BINARY') {
|
||||
columnType = 'BLOB';
|
||||
} else if (
|
||||
['TIMESTAMP', 'DATETIME', 'DATE', 'TIME'].includes(
|
||||
rawType
|
||||
)
|
||||
) {
|
||||
columnType = 'TIMESTAMP';
|
||||
} else if (
|
||||
['TEXT', 'VARCHAR', 'CHAR', 'CLOB', 'STRING'].includes(
|
||||
rawType
|
||||
) ||
|
||||
rawType.startsWith('VARCHAR') ||
|
||||
rawType.startsWith('CHAR')
|
||||
) {
|
||||
columnType = 'TEXT';
|
||||
} else {
|
||||
// Default to TEXT for unknown types
|
||||
columnType = 'TEXT';
|
||||
}
|
||||
|
||||
// Add column to the table
|
||||
table.columns.push({
|
||||
name: columnName,
|
||||
type: columnType,
|
||||
nullable: !isNotNull,
|
||||
primaryKey: isPrimaryKey,
|
||||
unique: isUnique,
|
||||
default: defaultValue,
|
||||
increment:
|
||||
isPrimaryKey &&
|
||||
isAutoIncrement &&
|
||||
columnType === 'INTEGER',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (table.columns.length > 0 || tableName === 'sqlite_sequence') {
|
||||
tables.push(table);
|
||||
}
|
||||
}
|
||||
|
||||
return tables;
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Shared utilities for detecting SQL dialect-specific syntax
|
||||
* Used across all validators to identify incompatible SQL dialects
|
||||
*/
|
||||
|
||||
import type { ValidationError } from './postgresql-validator';
|
||||
|
||||
interface DialectDetectionResult {
|
||||
detected: boolean;
|
||||
dialect: string;
|
||||
lines: number[];
|
||||
features: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Oracle-specific SQL syntax in the given SQL content
|
||||
*/
|
||||
export function detectOracleSQL(lines: string[]): DialectDetectionResult {
|
||||
const oracleTypeLines: number[] = [];
|
||||
const detectedFeatures = new Set<string>();
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const upperLine = line.trim().toUpperCase();
|
||||
|
||||
// Check for Oracle-specific data types
|
||||
if (upperLine.includes('VARCHAR2')) {
|
||||
detectedFeatures.add('VARCHAR2');
|
||||
oracleTypeLines.push(index + 1);
|
||||
}
|
||||
|
||||
if (
|
||||
upperLine.match(/\bNUMBER\s*\(/i) ||
|
||||
upperLine.match(/\bNUMBER\b(?!\s*\()/i)
|
||||
) {
|
||||
detectedFeatures.add('NUMBER');
|
||||
oracleTypeLines.push(index + 1);
|
||||
}
|
||||
|
||||
// Could add more Oracle-specific features in the future:
|
||||
// - CLOB, BLOB data types
|
||||
// - ROWNUM pseudo-column
|
||||
// - CONNECT BY for hierarchical queries
|
||||
// - MINUS set operator (vs EXCEPT in other DBs)
|
||||
});
|
||||
|
||||
return {
|
||||
detected: oracleTypeLines.length > 0,
|
||||
dialect: 'Oracle',
|
||||
lines: oracleTypeLines,
|
||||
features: Array.from(detectedFeatures),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Oracle SQL error for the target database type
|
||||
*/
|
||||
export function createOracleError(
|
||||
detection: DialectDetectionResult,
|
||||
targetDatabase: 'MySQL' | 'PostgreSQL' | 'SQL Server' | 'SQLite'
|
||||
): ValidationError {
|
||||
const lineList = detection.lines.slice(0, 5).join(', ');
|
||||
const moreLines =
|
||||
detection.lines.length > 5
|
||||
? ` and ${detection.lines.length - 5} more locations`
|
||||
: '';
|
||||
|
||||
const featuresText = detection.features.join(', ');
|
||||
|
||||
// Database-specific conversion suggestions
|
||||
const conversionMap = {
|
||||
MySQL: 'VARCHAR2 → VARCHAR, NUMBER → INT/DECIMAL/NUMERIC',
|
||||
PostgreSQL: 'VARCHAR2 → VARCHAR, NUMBER → NUMERIC/INTEGER',
|
||||
'SQL Server': 'VARCHAR2 → VARCHAR, NUMBER → INT/DECIMAL/NUMERIC',
|
||||
SQLite: 'VARCHAR2 → TEXT, NUMBER → INTEGER/REAL',
|
||||
};
|
||||
|
||||
return {
|
||||
line: detection.lines[0],
|
||||
message: `Oracle SQL syntax detected (${featuresText} types found on lines: ${lineList}${moreLines})`,
|
||||
type: 'syntax',
|
||||
suggestion: `This appears to be Oracle SQL. Please convert to ${targetDatabase} syntax: ${conversionMap[targetDatabase]}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect any foreign SQL dialect in the given content
|
||||
* Returns null if no foreign dialect is detected
|
||||
*/
|
||||
export function detectForeignDialect(
|
||||
lines: string[],
|
||||
targetDatabase: 'MySQL' | 'PostgreSQL' | 'SQL Server' | 'SQLite'
|
||||
): ValidationError | null {
|
||||
// Check for Oracle SQL
|
||||
const oracleDetection = detectOracleSQL(lines);
|
||||
if (oracleDetection.detected) {
|
||||
return createOracleError(oracleDetection, targetDatabase);
|
||||
}
|
||||
|
||||
// Future: Could add detection for other dialects
|
||||
// - DB2 specific syntax
|
||||
// - Teradata specific syntax
|
||||
// - etc.
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
ValidationError,
|
||||
ValidationWarning,
|
||||
} from './postgresql-validator';
|
||||
import { detectForeignDialect } from './dialect-detection';
|
||||
|
||||
/**
|
||||
* Validates MySQL SQL syntax
|
||||
@@ -35,16 +34,13 @@ export function validateMySQLDialect(sql: string): ValidationResult {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement MySQL-specific validation
|
||||
// For now, just do basic checks
|
||||
|
||||
// Check for common MySQL syntax patterns
|
||||
const lines = sql.split('\n');
|
||||
let tableCount = 0;
|
||||
|
||||
// Check for foreign SQL dialects
|
||||
const foreignDialectError = detectForeignDialect(lines, 'MySQL');
|
||||
if (foreignDialectError) {
|
||||
errors.push(foreignDialectError);
|
||||
}
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Provides user-friendly error messages for common SQL syntax issues
|
||||
*/
|
||||
|
||||
import { detectForeignDialect } from './dialect-detection';
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
@@ -214,13 +212,7 @@ export function validatePostgreSQLDialect(sql: string): ValidationResult {
|
||||
});
|
||||
}
|
||||
|
||||
// 9. Check for foreign SQL dialects
|
||||
const foreignDialectError = detectForeignDialect(lines, 'PostgreSQL');
|
||||
if (foreignDialectError) {
|
||||
errors.push(foreignDialectError);
|
||||
}
|
||||
|
||||
// 10. Count CREATE TABLE statements
|
||||
// 9. Count CREATE TABLE statements
|
||||
let tableCount = 0;
|
||||
const createTableRegex =
|
||||
/CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?(?:\s+ONLY)?\s+(?:"?[^"\s.]+?"?\.)?["'`]?[^"'`\s.(]+["'`]?/gi;
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
ValidationError,
|
||||
ValidationWarning,
|
||||
} from './postgresql-validator';
|
||||
import { detectForeignDialect } from './dialect-detection';
|
||||
|
||||
/**
|
||||
* Validates SQLite SQL syntax
|
||||
@@ -42,12 +41,6 @@ export function validateSQLiteDialect(sql: string): ValidationResult {
|
||||
const lines = sql.split('\n');
|
||||
let tableCount = 0;
|
||||
|
||||
// Check for foreign SQL dialects
|
||||
const foreignDialectError = detectForeignDialect(lines, 'SQLite');
|
||||
if (foreignDialectError) {
|
||||
errors.push(foreignDialectError);
|
||||
}
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
ValidationError,
|
||||
ValidationWarning,
|
||||
} from './postgresql-validator';
|
||||
import { detectForeignDialect } from './dialect-detection';
|
||||
|
||||
/**
|
||||
* Validates SQL Server SQL syntax
|
||||
@@ -35,16 +34,13 @@ export function validateSQLServerDialect(sql: string): ValidationResult {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement SQL Server-specific validation
|
||||
// For now, just do basic checks
|
||||
|
||||
// Check for common SQL Server syntax patterns
|
||||
const lines = sql.split('\n');
|
||||
let tableCount = 0;
|
||||
|
||||
// Check for foreign SQL dialects
|
||||
const foreignDialectError = detectForeignDialect(lines, 'SQL Server');
|
||||
if (foreignDialectError) {
|
||||
errors.push(foreignDialectError);
|
||||
}
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
|
||||
@@ -620,6 +620,7 @@ export const applyDBMLChanges = ({
|
||||
...sourceDiagram,
|
||||
tables: finalTables.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
areas: targetDiagram.areas,
|
||||
notes: targetDiagram.notes,
|
||||
relationships: sortedRelationships,
|
||||
dependencies: updatedDependencies,
|
||||
customTypes: updatedCustomTypes,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Table "public"."guy_table" {
|
||||
"id" integer [pk, not null]
|
||||
"created_at" timestamp [not null]
|
||||
"created_at" "timestamp without time zone" [not null]
|
||||
"column3" text
|
||||
"arrayfield" text[]
|
||||
"field_5" "character varying"
|
||||
|
||||
7
src/lib/dbml/dbml-export/__tests__/cases/4.dbml
Normal file
7
src/lib/dbml/dbml-export/__tests__/cases/4.dbml
Normal file
@@ -0,0 +1,7 @@
|
||||
Table "public"."orders" {
|
||||
"order_id" integer [pk, not null, increment]
|
||||
"customer_id" integer [not null]
|
||||
"order_date" date [not null, default: `CURRENT_DATE`]
|
||||
"total_amount" numeric [not null, default: 0]
|
||||
"status" varchar(50) [not null, default: 'Pending']
|
||||
}
|
||||
1
src/lib/dbml/dbml-export/__tests__/cases/4.json
Normal file
1
src/lib/dbml/dbml-export/__tests__/cases/4.json
Normal file
@@ -0,0 +1 @@
|
||||
{"id":"6b81a1787207","name":"SQL Import (postgresql)","createdAt":"2025-09-15T08:46:26.747Z","updatedAt":"2025-09-17T11:32:13.876Z","databaseType":"postgresql","tables":[{"id":"5ytf0yj9etpmm7mhmhvpu8kfj","name":"orders","schema":"public","order":1,"fields":[{"id":"w7l77cy9hylvlitdovt4ktdmk","name":"order_id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":true,"unique":false,"default":"","createdAt":1757925986747,"increment":true},{"id":"vz7747t5fxrb62v1eepmahv9v","name":"customer_id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","createdAt":1757925986747,"increment":false},{"id":"geq9qy6sv4ozl2lg9fvcyzxpf","name":"order_date","type":{"name":"date","id":"date","usageLevel":1},"nullable":false,"primaryKey":false,"unique":false,"default":"CURRENT_DATE()","createdAt":1757925986747,"increment":false},{"id":"z928n7umvpec79t2eif7kmde9","name":"total_amount","type":{"name":"numeric","id":"numeric","fieldAttributes":{"precision":{"max":999,"min":1,"default":10},"scale":{"max":999,"min":0,"default":2}}},"nullable":false,"primaryKey":false,"unique":false,"default":"0","createdAt":1757925986747,"increment":false},{"id":"7bkrd0rp1s17bi1lnle6pesc7","name":"status","type":{"name":"varchar","id":"varchar","fieldAttributes":{"hasCharMaxLength":true},"usageLevel":1},"nullable":false,"primaryKey":false,"unique":false,"default":"'Pending'","createdAt":1757925986747,"increment":false,"characterMaximumLength":"50"}],"indexes":[],"x":113,"y":747,"color":"#8eb7ff","isView":false,"createdAt":1757925986747,"diagramId":"6b81a1787207","parentAreaId":null}],"relationships":[],"dependencies":[],"storageMode":"project","lastProjectSavedAt":"2025-09-17T11:32:13.876Z","areas":[],"creationMethod":"imported","customTypes":[]}
|
||||
129
src/lib/dbml/dbml-export/__tests__/cases/5.inline.dbml
Normal file
129
src/lib/dbml/dbml-export/__tests__/cases/5.inline.dbml
Normal file
@@ -0,0 +1,129 @@
|
||||
Enum "cbhpm_entradas_tipo" {
|
||||
"grupo"
|
||||
"subgrupo"
|
||||
"procedimento"
|
||||
}
|
||||
|
||||
Enum "cid_entradas_tipo" {
|
||||
"capitulo"
|
||||
"agrupamento"
|
||||
"categoria"
|
||||
"subcategoria"
|
||||
}
|
||||
|
||||
Enum "digital_signature_provider" {
|
||||
"soluti"
|
||||
"valid"
|
||||
}
|
||||
|
||||
Enum "impresso_posicao" {
|
||||
"start"
|
||||
"center"
|
||||
"end"
|
||||
}
|
||||
|
||||
Enum "otp_provider" {
|
||||
"clinic"
|
||||
"soluti_bird_id"
|
||||
}
|
||||
|
||||
Enum "tipo_cobranca" {
|
||||
"valor"
|
||||
"porte"
|
||||
}
|
||||
|
||||
Enum "tipo_contato_movel" {
|
||||
"celular"
|
||||
"telefone_residencial"
|
||||
"telefone_comercial"
|
||||
}
|
||||
|
||||
Enum "tipo_contrato" {
|
||||
"trial"
|
||||
"common"
|
||||
}
|
||||
|
||||
Enum "tipo_endereco" {
|
||||
"residencial"
|
||||
"comercial"
|
||||
"cobranca"
|
||||
}
|
||||
|
||||
Enum "tipo_espectro_autista" {
|
||||
"leve"
|
||||
"moderado"
|
||||
"severo"
|
||||
}
|
||||
|
||||
Enum "tipo_estado_civil" {
|
||||
"nao_infomado"
|
||||
"solteiro"
|
||||
"casado"
|
||||
"divorciado"
|
||||
"viuvo"
|
||||
}
|
||||
|
||||
Enum "tipo_etnia" {
|
||||
"nao_infomado"
|
||||
"branca"
|
||||
"preta"
|
||||
"parda"
|
||||
"amarela"
|
||||
"indigena"
|
||||
}
|
||||
|
||||
Enum "tipo_excecao" {
|
||||
"bloqueio"
|
||||
"compromisso"
|
||||
}
|
||||
|
||||
Enum "tipo_metodo_reajuste" {
|
||||
"percentual"
|
||||
"valor"
|
||||
}
|
||||
|
||||
Enum "tipo_pessoa" {
|
||||
"fisica"
|
||||
"juridica"
|
||||
}
|
||||
|
||||
Enum "tipo_procedimento" {
|
||||
"consulta"
|
||||
"exame_laboratorial"
|
||||
"exame_imagem"
|
||||
"procedimento_clinico"
|
||||
"procedimento_cirurgico"
|
||||
"terapia"
|
||||
"outros"
|
||||
}
|
||||
|
||||
Enum "tipo_relacionamento" {
|
||||
"pai"
|
||||
"mae"
|
||||
"conjuge"
|
||||
"filho_a"
|
||||
"tutor_legal"
|
||||
"contato_emergencia"
|
||||
"outro"
|
||||
}
|
||||
|
||||
Enum "tipo_sexo" {
|
||||
"nao_infomado"
|
||||
"masculino"
|
||||
"feminino"
|
||||
"intersexo"
|
||||
}
|
||||
|
||||
Enum "tipo_status_agendamento" {
|
||||
"em espera"
|
||||
"faltou"
|
||||
"ok"
|
||||
}
|
||||
|
||||
Table "public"."organizacao_cfg_impressos" {
|
||||
"id_organizacao" integer [pk, not null, ref: < "public"."organizacao"."id"]
|
||||
}
|
||||
|
||||
Table "public"."organizacao" {
|
||||
"id" integer [pk, not null]
|
||||
}
|
||||
1
src/lib/dbml/dbml-export/__tests__/cases/5.json
Normal file
1
src/lib/dbml/dbml-export/__tests__/cases/5.json
Normal file
File diff suppressed because one or more lines are too long
14
src/lib/dbml/dbml-export/__tests__/cases/6.dbml
Normal file
14
src/lib/dbml/dbml-export/__tests__/cases/6.dbml
Normal file
@@ -0,0 +1,14 @@
|
||||
Table "users" {
|
||||
"id" integer [pk, not null, increment]
|
||||
"username" varchar(100) [unique, not null]
|
||||
"email" varchar(255) [not null]
|
||||
}
|
||||
|
||||
Table "posts" {
|
||||
"post_id" bigint [pk, not null, increment]
|
||||
"user_id" integer [not null]
|
||||
"title" varchar(200) [not null]
|
||||
"order_num" integer [not null, increment]
|
||||
}
|
||||
|
||||
Ref "fk_0_fk_posts_users":"users"."id" < "posts"."user_id"
|
||||
1
src/lib/dbml/dbml-export/__tests__/cases/6.json
Normal file
1
src/lib/dbml/dbml-export/__tests__/cases/6.json
Normal file
@@ -0,0 +1 @@
|
||||
{"id":"test_auto_increment","name":"Auto Increment Test (mysql)","createdAt":"2025-01-20T00:00:00.000Z","updatedAt":"2025-01-20T00:00:00.000Z","databaseType":"mysql","tables":[{"id":"table1","name":"users","order":1,"fields":[{"id":"field1","name":"id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":true,"unique":false,"default":"","increment":true,"createdAt":1705708800000},{"id":"field2","name":"username","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":true,"default":"","increment":false,"characterMaximumLength":"100","createdAt":1705708800000},{"id":"field3","name":"email","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"characterMaximumLength":"255","createdAt":1705708800000}],"indexes":[],"x":100,"y":100,"color":"#8eb7ff","isView":false,"createdAt":1705708800000},{"id":"table2","name":"posts","order":2,"fields":[{"id":"field4","name":"post_id","type":{"id":"bigint","name":"bigint"},"nullable":false,"primaryKey":true,"unique":false,"default":"","increment":true,"createdAt":1705708800000},{"id":"field5","name":"user_id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"createdAt":1705708800000},{"id":"field6","name":"title","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"characterMaximumLength":"200","createdAt":1705708800000},{"id":"field7","name":"order_num","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":true,"createdAt":1705708800000}],"indexes":[],"x":300,"y":100,"color":"#8eb7ff","isView":false,"createdAt":1705708800000}],"relationships":[{"id":"rel1","name":"fk_posts_users","sourceTableId":"table2","targetTableId":"table1","sourceFieldId":"field5","targetFieldId":"field1","type":"one_to_many","sourceCardinality":"many","targetCardinality":"one","createdAt":1705708800000}],"dependencies":[],"storageMode":"project","areas":[],"creationMethod":"manual","customTypes":[]}
|
||||
205
src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts
Normal file
205
src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateDBMLFromDiagram } from '../dbml-export';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
import type { Diagram } from '@/lib/domain/diagram';
|
||||
import { generateId, generateDiagramId } from '@/lib/utils';
|
||||
|
||||
describe('DBML Export - Empty Tables', () => {
|
||||
it('should filter out tables with no fields', () => {
|
||||
const diagram: Diagram = {
|
||||
id: generateDiagramId(),
|
||||
name: 'Test Diagram',
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
tables: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'valid_table',
|
||||
schema: 'public',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'id',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: true,
|
||||
unique: true,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
color: '#8eb7ff',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'empty_table',
|
||||
schema: 'public',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [], // Empty fields array
|
||||
indexes: [],
|
||||
color: '#8eb7ff',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'another_valid_table',
|
||||
schema: 'public',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'name',
|
||||
type: { id: 'varchar', name: 'varchar' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
color: '#8eb7ff',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = generateDBMLFromDiagram(diagram);
|
||||
|
||||
// Verify the DBML doesn't contain the empty table
|
||||
expect(result.inlineDbml).not.toContain('empty_table');
|
||||
expect(result.standardDbml).not.toContain('empty_table');
|
||||
|
||||
// Verify the valid tables are still present
|
||||
expect(result.inlineDbml).toContain('valid_table');
|
||||
expect(result.inlineDbml).toContain('another_valid_table');
|
||||
});
|
||||
|
||||
it('should handle diagram with only empty tables', () => {
|
||||
const diagram: Diagram = {
|
||||
id: generateDiagramId(),
|
||||
name: 'Test Diagram',
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
tables: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'empty_table_1',
|
||||
schema: 'public',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [],
|
||||
indexes: [],
|
||||
color: '#8eb7ff',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'empty_table_2',
|
||||
schema: 'public',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [],
|
||||
indexes: [],
|
||||
color: '#8eb7ff',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = generateDBMLFromDiagram(diagram);
|
||||
|
||||
// Should not error and should return empty DBML (or just enums if any)
|
||||
expect(result.inlineDbml).toBeTruthy();
|
||||
expect(result.standardDbml).toBeTruthy();
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should filter out table that becomes empty after removing invalid fields', () => {
|
||||
const diagram: Diagram = {
|
||||
id: generateDiagramId(),
|
||||
name: 'Test Diagram',
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
tables: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'table_with_only_empty_field_names',
|
||||
schema: 'public',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: '', // Empty field name - will be filtered
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: '', // Empty field name - will be filtered
|
||||
type: { id: 'varchar', name: 'varchar' },
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
color: '#8eb7ff',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'valid_table',
|
||||
schema: 'public',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'id',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: true,
|
||||
unique: true,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
color: '#8eb7ff',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = generateDBMLFromDiagram(diagram);
|
||||
|
||||
// Table with only empty field names should be filtered out
|
||||
expect(result.inlineDbml).not.toContain(
|
||||
'table_with_only_empty_field_names'
|
||||
);
|
||||
// Valid table should remain
|
||||
expect(result.inlineDbml).toContain('valid_table');
|
||||
});
|
||||
});
|
||||
@@ -4,64 +4,74 @@ import { generateDBMLFromDiagram } from '../dbml-export';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('DBML Export - Diagram Case 1 Tests', () => {
|
||||
const testCase = (caseNumber: string) => {
|
||||
// Read the JSON file
|
||||
const jsonPath = path.join(__dirname, 'cases', `${caseNumber}.json`);
|
||||
const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
|
||||
|
||||
// Parse the JSON and convert to diagram
|
||||
const diagram = diagramFromJSONInput(jsonContent);
|
||||
|
||||
// Generate DBML from the diagram
|
||||
const result = generateDBMLFromDiagram(diagram);
|
||||
|
||||
// Check for both regular and inline DBML files
|
||||
const regularDbmlPath = path.join(__dirname, 'cases', `${caseNumber}.dbml`);
|
||||
const inlineDbmlPath = path.join(
|
||||
__dirname,
|
||||
'cases',
|
||||
`${caseNumber}.inline.dbml`
|
||||
);
|
||||
|
||||
const hasRegularDbml = fs.existsSync(regularDbmlPath);
|
||||
const hasInlineDbml = fs.existsSync(inlineDbmlPath);
|
||||
|
||||
// Test regular DBML if file exists
|
||||
if (hasRegularDbml) {
|
||||
const expectedRegularDBML = fs.readFileSync(regularDbmlPath, 'utf-8');
|
||||
expect(result.standardDbml).toBe(expectedRegularDBML);
|
||||
}
|
||||
|
||||
// Test inline DBML if file exists
|
||||
if (hasInlineDbml) {
|
||||
const expectedInlineDBML = fs.readFileSync(inlineDbmlPath, 'utf-8');
|
||||
expect(result.inlineDbml).toBe(expectedInlineDBML);
|
||||
}
|
||||
|
||||
// Ensure at least one DBML file exists
|
||||
if (!hasRegularDbml && !hasInlineDbml) {
|
||||
throw new Error(
|
||||
`No DBML file found for test case ${caseNumber}. Expected either ${caseNumber}.dbml or ${caseNumber}.inline.dbml`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
describe('DBML Export cases', () => {
|
||||
it('should handle case 1 diagram', { timeout: 30000 }, async () => {
|
||||
// Read the JSON file
|
||||
const jsonPath = path.join(__dirname, 'cases', '1.json');
|
||||
const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
|
||||
|
||||
// Parse the JSON and convert to diagram
|
||||
const diagram = diagramFromJSONInput(jsonContent);
|
||||
|
||||
// Generate DBML from the diagram
|
||||
const result = generateDBMLFromDiagram(diagram);
|
||||
const generatedDBML = result.standardDbml;
|
||||
|
||||
// Read the expected DBML file
|
||||
const dbmlPath = path.join(__dirname, 'cases', '1.dbml');
|
||||
const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8');
|
||||
|
||||
// Compare the generated DBML with the expected DBML
|
||||
expect(generatedDBML).toBe(expectedDBML);
|
||||
testCase('1');
|
||||
});
|
||||
|
||||
it('should handle case 2 diagram', { timeout: 30000 }, async () => {
|
||||
// Read the JSON file
|
||||
const jsonPath = path.join(__dirname, 'cases', '2.json');
|
||||
const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
|
||||
|
||||
// Parse the JSON and convert to diagram
|
||||
const diagram = diagramFromJSONInput(jsonContent);
|
||||
|
||||
// Generate DBML from the diagram
|
||||
const result = generateDBMLFromDiagram(diagram);
|
||||
const generatedDBML = result.standardDbml;
|
||||
|
||||
// Read the expected DBML file
|
||||
const dbmlPath = path.join(__dirname, 'cases', '2.dbml');
|
||||
const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8');
|
||||
|
||||
// Compare the generated DBML with the expected DBML
|
||||
expect(generatedDBML).toBe(expectedDBML);
|
||||
testCase('2');
|
||||
});
|
||||
|
||||
it('should handle case 3 diagram', { timeout: 30000 }, async () => {
|
||||
// Read the JSON file
|
||||
const jsonPath = path.join(__dirname, 'cases', '3.json');
|
||||
const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
|
||||
|
||||
// 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);
|
||||
testCase('3');
|
||||
});
|
||||
|
||||
it('should handle case 4 diagram', { timeout: 30000 }, async () => {
|
||||
testCase('4');
|
||||
});
|
||||
|
||||
it('should handle case 5 diagram', { timeout: 30000 }, async () => {
|
||||
testCase('5');
|
||||
});
|
||||
|
||||
it(
|
||||
'should handle case 6 diagram - auto increment',
|
||||
{ timeout: 30000 },
|
||||
async () => {
|
||||
testCase('6');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateDBMLFromDiagram } from '../dbml-export';
|
||||
import { importDBMLToDiagram } from '../../dbml-import/dbml-import';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
import type { Diagram } from '@/lib/domain/diagram';
|
||||
import { generateId, generateDiagramId } from '@/lib/utils';
|
||||
|
||||
describe('DBML Export - Timestamp with Time Zone', () => {
|
||||
it('should preserve "timestamp with time zone" type through export and reimport', async () => {
|
||||
// Create a diagram with timestamp with time zone field
|
||||
const diagram: Diagram = {
|
||||
id: generateDiagramId(),
|
||||
name: 'Test Diagram',
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
tables: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'events',
|
||||
schema: 'public',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'id',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: true,
|
||||
unique: true,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'created_at',
|
||||
type: {
|
||||
id: 'timestamp_with_time_zone',
|
||||
name: 'timestamp with time zone',
|
||||
},
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'updated_at',
|
||||
type: {
|
||||
id: 'timestamp_without_time_zone',
|
||||
name: 'timestamp without time zone',
|
||||
},
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
color: '#8eb7ff',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Export to DBML
|
||||
const exportResult = generateDBMLFromDiagram(diagram);
|
||||
|
||||
// Verify the DBML contains quoted multi-word types
|
||||
expect(exportResult.inlineDbml).toContain('"timestamp with time zone"');
|
||||
expect(exportResult.inlineDbml).toContain(
|
||||
'"timestamp without time zone"'
|
||||
);
|
||||
|
||||
// Reimport the DBML
|
||||
const reimportedDiagram = await importDBMLToDiagram(
|
||||
exportResult.inlineDbml,
|
||||
{
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify the types are preserved
|
||||
const table = reimportedDiagram.tables?.find(
|
||||
(t) => t.name === 'events'
|
||||
);
|
||||
expect(table).toBeDefined();
|
||||
|
||||
const createdAtField = table?.fields.find(
|
||||
(f) => f.name === 'created_at'
|
||||
);
|
||||
const updatedAtField = table?.fields.find(
|
||||
(f) => f.name === 'updated_at'
|
||||
);
|
||||
|
||||
expect(createdAtField?.type.name).toBe('timestamp with time zone');
|
||||
expect(updatedAtField?.type.name).toBe('timestamp without time zone');
|
||||
});
|
||||
|
||||
it('should handle time with time zone types', async () => {
|
||||
const diagram: Diagram = {
|
||||
id: generateDiagramId(),
|
||||
name: 'Test Diagram',
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
tables: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'schedules',
|
||||
schema: 'public',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'id',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: true,
|
||||
unique: true,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'start_time',
|
||||
type: {
|
||||
id: 'time_with_time_zone',
|
||||
name: 'time with time zone',
|
||||
},
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'end_time',
|
||||
type: {
|
||||
id: 'time_without_time_zone',
|
||||
name: 'time without time zone',
|
||||
},
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
color: '#8eb7ff',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const exportResult = generateDBMLFromDiagram(diagram);
|
||||
|
||||
expect(exportResult.inlineDbml).toContain('"time with time zone"');
|
||||
expect(exportResult.inlineDbml).toContain('"time without time zone"');
|
||||
|
||||
const reimportedDiagram = await importDBMLToDiagram(
|
||||
exportResult.inlineDbml,
|
||||
{
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
}
|
||||
);
|
||||
|
||||
const table = reimportedDiagram.tables?.find(
|
||||
(t) => t.name === 'schedules'
|
||||
);
|
||||
const startTimeField = table?.fields.find(
|
||||
(f) => f.name === 'start_time'
|
||||
);
|
||||
const endTimeField = table?.fields.find((f) => f.name === 'end_time');
|
||||
|
||||
expect(startTimeField?.type.name).toBe('time with time zone');
|
||||
expect(endTimeField?.type.name).toBe('time without time zone');
|
||||
});
|
||||
|
||||
it('should handle double precision type', async () => {
|
||||
const diagram: Diagram = {
|
||||
id: generateDiagramId(),
|
||||
name: 'Test Diagram',
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
tables: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'measurements',
|
||||
schema: 'public',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'id',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: true,
|
||||
unique: true,
|
||||
nullable: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: generateId(),
|
||||
name: 'value',
|
||||
type: {
|
||||
id: 'double_precision',
|
||||
name: 'double precision',
|
||||
},
|
||||
primaryKey: false,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
color: '#8eb7ff',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const exportResult = generateDBMLFromDiagram(diagram);
|
||||
|
||||
expect(exportResult.inlineDbml).toContain('"double precision"');
|
||||
|
||||
const reimportedDiagram = await importDBMLToDiagram(
|
||||
exportResult.inlineDbml,
|
||||
{
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
}
|
||||
);
|
||||
|
||||
const table = reimportedDiagram.tables?.find(
|
||||
(t) => t.name === 'measurements'
|
||||
);
|
||||
const valueField = table?.fields.find((f) => f.name === 'value');
|
||||
|
||||
expect(valueField?.type.name).toBe('double precision');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { exportBaseSQL } from '@/lib/data/sql-export/export-sql-script';
|
||||
import type { Diagram } from '@/lib/domain/diagram';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
import type { DBTable } from '@/lib/domain/db-table';
|
||||
import { type DBField } from '@/lib/domain/db-field';
|
||||
import type { DBCustomType } from '@/lib/domain/db-custom-type';
|
||||
import { DBCustomTypeKind } from '@/lib/domain/db-custom-type';
|
||||
|
||||
@@ -502,38 +501,35 @@ const convertToInlineRefs = (dbml: string): string => {
|
||||
return cleanedDbml;
|
||||
};
|
||||
|
||||
// Function to check for DBML reserved keywords
|
||||
const isDBMLKeyword = (name: string): boolean => {
|
||||
const keywords = new Set([
|
||||
'YES',
|
||||
'NO',
|
||||
'TRUE',
|
||||
'FALSE',
|
||||
'NULL', // DBML reserved keywords (boolean literals)
|
||||
]);
|
||||
return keywords.has(name.toUpperCase());
|
||||
};
|
||||
|
||||
// Function to check for SQL keywords (add more if needed)
|
||||
const isSQLKeyword = (name: string): boolean => {
|
||||
const keywords = new Set(['CASE', 'ORDER', 'GROUP', 'FROM', 'TO', 'USER']); // Common SQL keywords
|
||||
return keywords.has(name.toUpperCase());
|
||||
};
|
||||
|
||||
// Function to remove duplicate relationships from the diagram
|
||||
const deduplicateRelationships = (diagram: Diagram): Diagram => {
|
||||
if (!diagram.relationships) return diagram;
|
||||
|
||||
const seenRelationships = new Set<string>();
|
||||
const seenBidirectional = new Set<string>();
|
||||
const uniqueRelationships = diagram.relationships.filter((rel) => {
|
||||
// Create a unique key based on the relationship endpoints
|
||||
const relationshipKey = `${rel.sourceTableId}-${rel.sourceFieldId}->${rel.targetTableId}-${rel.targetFieldId}`;
|
||||
|
||||
// Create a normalized key that's the same for both directions
|
||||
const normalizedKey = [
|
||||
`${rel.sourceTableId}-${rel.sourceFieldId}`,
|
||||
`${rel.targetTableId}-${rel.targetFieldId}`,
|
||||
]
|
||||
.sort()
|
||||
.join('<->');
|
||||
|
||||
if (seenRelationships.has(relationshipKey)) {
|
||||
return false; // Skip duplicate
|
||||
return false; // Skip exact duplicate
|
||||
}
|
||||
|
||||
if (seenBidirectional.has(normalizedKey)) {
|
||||
// This is a bidirectional relationship, skip the second one
|
||||
return false;
|
||||
}
|
||||
|
||||
seenRelationships.add(relationshipKey);
|
||||
seenBidirectional.add(normalizedKey);
|
||||
return true; // Keep unique relationship
|
||||
});
|
||||
|
||||
@@ -543,48 +539,6 @@ const deduplicateRelationships = (diagram: Diagram): Diagram => {
|
||||
};
|
||||
};
|
||||
|
||||
// Function to append comment statements for renamed tables and fields
|
||||
const appendRenameComments = (
|
||||
baseScript: string,
|
||||
sqlRenamedTables: Map<string, string>,
|
||||
fieldRenames: Array<{
|
||||
table: string;
|
||||
originalName: string;
|
||||
newName: string;
|
||||
}>,
|
||||
finalDiagramForExport: Diagram
|
||||
): string => {
|
||||
let script = baseScript;
|
||||
|
||||
// Append COMMENTS for tables renamed due to SQL keywords
|
||||
sqlRenamedTables.forEach((originalName, newName) => {
|
||||
const escapedOriginal = originalName.replace(/'/g, "\\'");
|
||||
// Find the table to get its schema
|
||||
const table = finalDiagramForExport.tables?.find(
|
||||
(t) => t.name === newName
|
||||
);
|
||||
const tableIdentifier = table?.schema
|
||||
? `"${table.schema}"."${newName}"`
|
||||
: `"${newName}"`;
|
||||
script += `\nCOMMENT ON TABLE ${tableIdentifier} IS 'Original name was "${escapedOriginal}" (renamed due to SQL keyword conflict).';`;
|
||||
});
|
||||
|
||||
// Append COMMENTS for fields renamed due to SQL keyword conflicts
|
||||
fieldRenames.forEach(({ table, originalName, newName }) => {
|
||||
const escapedOriginal = originalName.replace(/'/g, "\\'");
|
||||
// Find the table to get its schema
|
||||
const tableObj = finalDiagramForExport.tables?.find(
|
||||
(t) => t.name === table
|
||||
);
|
||||
const tableIdentifier = tableObj?.schema
|
||||
? `"${tableObj.schema}"."${table}"`
|
||||
: `"${table}"`;
|
||||
script += `\nCOMMENT ON COLUMN ${tableIdentifier}."${newName}" IS 'Original name was "${escapedOriginal}" (renamed due to SQL keyword conflict).';`;
|
||||
});
|
||||
|
||||
return script;
|
||||
};
|
||||
|
||||
// Fix DBML formatting to ensure consistent display of char and varchar types
|
||||
const normalizeCharTypeFormat = (dbml: string): string => {
|
||||
// Replace "char (N)" with "char(N)" to match varchar's formatting
|
||||
@@ -629,6 +583,54 @@ const fixMultilineTableNames = (dbml: string): string => {
|
||||
);
|
||||
};
|
||||
|
||||
// Restore increment attribute for auto-incrementing fields
|
||||
const restoreIncrementAttribute = (dbml: string, tables: DBTable[]): string => {
|
||||
if (!tables || tables.length === 0) return dbml;
|
||||
|
||||
let result = dbml;
|
||||
|
||||
tables.forEach((table) => {
|
||||
// Find fields with increment=true
|
||||
const incrementFields = table.fields.filter((f) => f.increment);
|
||||
|
||||
incrementFields.forEach((field) => {
|
||||
// Build the table identifier pattern
|
||||
const tableIdentifier = table.schema
|
||||
? `"${table.schema.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\."${table.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`
|
||||
: `"${table.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`;
|
||||
|
||||
// Escape field name for regex
|
||||
const escapedFieldName = field.name.replace(
|
||||
/[.*+?^${}()|[\]\\]/g,
|
||||
'\\$&'
|
||||
);
|
||||
|
||||
// Pattern to match the field line with existing attributes in brackets
|
||||
// Matches: "field_name" type [existing, attributes]
|
||||
const fieldPattern = new RegExp(
|
||||
`(Table ${tableIdentifier} \\{[^}]*?^\\s*"${escapedFieldName}"[^\\[\\n]+)(\\[[^\\]]*\\])`,
|
||||
'gms'
|
||||
);
|
||||
|
||||
result = result.replace(
|
||||
fieldPattern,
|
||||
(match, fieldPart, brackets) => {
|
||||
// Check if increment already exists
|
||||
if (brackets.includes('increment')) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// Add increment to the attributes
|
||||
const newBrackets = brackets.replace(']', ', increment]');
|
||||
return fieldPart + newBrackets;
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Restore composite primary key names in the DBML
|
||||
const restoreCompositePKNames = (dbml: string, tables: DBTable[]): string => {
|
||||
if (!tables || tables.length === 0) return dbml;
|
||||
@@ -778,9 +780,17 @@ const restoreTableSchemas = (dbml: string, tables: DBTable[]): string => {
|
||||
return result;
|
||||
};
|
||||
|
||||
// Function to extract only Ref statements from DBML
|
||||
const extractRelationshipsDbml = (dbml: string): string => {
|
||||
const lines = dbml.split('\n');
|
||||
const refLines = lines.filter((line) => line.trim().startsWith('Ref '));
|
||||
return refLines.join('\n').trim();
|
||||
};
|
||||
|
||||
export interface DBMLExportResult {
|
||||
standardDbml: string;
|
||||
inlineDbml: string;
|
||||
relationshipsDbml: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -797,31 +807,37 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// Remove duplicate tables (consider both schema and table name)
|
||||
// Filter out empty tables and duplicates in a single pass for performance
|
||||
const seenTableIdentifiers = new Set<string>();
|
||||
const uniqueTables = sanitizedTables.filter((table) => {
|
||||
const tablesWithFields = sanitizedTables.filter((table) => {
|
||||
// Skip tables with no fields (empty tables cause DBML export to fail)
|
||||
if (table.fields.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create a unique identifier combining schema and table name
|
||||
const tableIdentifier = table.schema
|
||||
? `${table.schema}.${table.name}`
|
||||
: table.name;
|
||||
|
||||
// Skip duplicate tables
|
||||
if (seenTableIdentifiers.has(tableIdentifier)) {
|
||||
return false; // Skip duplicate
|
||||
return false;
|
||||
}
|
||||
seenTableIdentifiers.add(tableIdentifier);
|
||||
return true; // Keep unique table
|
||||
return true; // Keep unique, non-empty table
|
||||
});
|
||||
|
||||
// Create the base filtered diagram structure
|
||||
const filteredDiagram: Diagram = {
|
||||
...diagram,
|
||||
tables: uniqueTables,
|
||||
tables: tablesWithFields,
|
||||
relationships:
|
||||
diagram.relationships?.filter((rel) => {
|
||||
const sourceTable = uniqueTables.find(
|
||||
const sourceTable = tablesWithFields.find(
|
||||
(t) => t.id === rel.sourceTableId
|
||||
);
|
||||
const targetTable = uniqueTables.find(
|
||||
const targetTable = tablesWithFields.find(
|
||||
(t) => t.id === rel.targetTableId
|
||||
);
|
||||
const sourceFieldExists = sourceTable?.fields.some(
|
||||
@@ -843,105 +859,33 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
|
||||
// Sanitize field names ('from'/'to' in 'relation' table)
|
||||
const cleanDiagram = fixProblematicFieldNames(filteredDiagram);
|
||||
|
||||
// --- Final sanitization and renaming pass ---
|
||||
// Only rename keywords for PostgreSQL/SQLite
|
||||
// For other databases, we'll wrap problematic names in quotes instead
|
||||
const shouldRenameKeywords =
|
||||
diagram.databaseType === DatabaseType.POSTGRESQL ||
|
||||
diagram.databaseType === DatabaseType.SQLITE;
|
||||
const sqlRenamedTables = new Map<string, string>();
|
||||
const fieldRenames: Array<{
|
||||
table: string;
|
||||
originalName: string;
|
||||
newName: string;
|
||||
}> = [];
|
||||
|
||||
// Simplified processing - just handle duplicate field names
|
||||
const processTable = (table: DBTable) => {
|
||||
const originalName = table.name;
|
||||
let safeTableName = originalName;
|
||||
|
||||
// If name contains spaces or special characters, wrap in quotes
|
||||
if (/[^\w]/.test(originalName)) {
|
||||
safeTableName = `"${originalName.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
// Rename table if it's a keyword (PostgreSQL/SQLite only)
|
||||
if (
|
||||
shouldRenameKeywords &&
|
||||
(isDBMLKeyword(originalName) || isSQLKeyword(originalName))
|
||||
) {
|
||||
const newName = `${originalName}_table`;
|
||||
sqlRenamedTables.set(newName, originalName);
|
||||
safeTableName = /[^\w]/.test(newName)
|
||||
? `"${newName.replace(/"/g, '\\"')}"`
|
||||
: newName;
|
||||
}
|
||||
// For other databases, just quote DBML keywords
|
||||
else if (!shouldRenameKeywords && isDBMLKeyword(originalName)) {
|
||||
safeTableName = `"${originalName.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
const fieldNameCounts = new Map<string, number>();
|
||||
const processedFields = table.fields.map((field) => {
|
||||
let finalSafeName = field.name;
|
||||
|
||||
// If field name contains spaces or special characters, wrap in quotes
|
||||
if (/[^\w]/.test(field.name)) {
|
||||
finalSafeName = `"${field.name.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
// Handle duplicate field names
|
||||
const count = fieldNameCounts.get(field.name) || 0;
|
||||
if (count > 0) {
|
||||
const newName = `${field.name}_${count + 1}`;
|
||||
finalSafeName = /[^\w]/.test(newName)
|
||||
? `"${newName.replace(/"/g, '\\"')}"`
|
||||
: newName;
|
||||
return {
|
||||
...field,
|
||||
name: newName,
|
||||
};
|
||||
}
|
||||
fieldNameCounts.set(field.name, count + 1);
|
||||
|
||||
// Create sanitized field
|
||||
const sanitizedField: DBField = {
|
||||
...field,
|
||||
name: finalSafeName,
|
||||
};
|
||||
|
||||
// Rename field if it's a keyword (PostgreSQL/SQLite only)
|
||||
if (
|
||||
shouldRenameKeywords &&
|
||||
(isDBMLKeyword(field.name) || isSQLKeyword(field.name))
|
||||
) {
|
||||
const newFieldName = `${field.name}_field`;
|
||||
fieldRenames.push({
|
||||
table: safeTableName,
|
||||
originalName: field.name,
|
||||
newName: newFieldName,
|
||||
});
|
||||
sanitizedField.name = /[^\w]/.test(newFieldName)
|
||||
? `"${newFieldName.replace(/"/g, '\\"')}"`
|
||||
: newFieldName;
|
||||
}
|
||||
// For other databases, just quote DBML keywords
|
||||
else if (!shouldRenameKeywords && isDBMLKeyword(field.name)) {
|
||||
sanitizedField.name = `"${field.name.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
return sanitizedField;
|
||||
return field;
|
||||
});
|
||||
|
||||
return {
|
||||
...table,
|
||||
name: safeTableName,
|
||||
fields: processedFields,
|
||||
indexes: (table.indexes || [])
|
||||
.filter((index) => !index.isPrimaryKey) // Filter out PK indexes as they're handled separately
|
||||
.map((index) => ({
|
||||
...index,
|
||||
name: index.name
|
||||
? /[^\w]/.test(index.name)
|
||||
? `"${index.name.replace(/"/g, '\\"')}"`
|
||||
: index.name
|
||||
: `idx_${Math.random().toString(36).substring(2, 8)}`,
|
||||
name:
|
||||
index.name ||
|
||||
`idx_${Math.random().toString(36).substring(2, 8)}`,
|
||||
})),
|
||||
};
|
||||
};
|
||||
@@ -979,19 +923,6 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
|
||||
|
||||
baseScript = sanitizeSQLforDBML(baseScript);
|
||||
|
||||
// Append comments for renamed tables and fields (PostgreSQL/SQLite only)
|
||||
if (
|
||||
shouldRenameKeywords &&
|
||||
(sqlRenamedTables.size > 0 || fieldRenames.length > 0)
|
||||
) {
|
||||
baseScript = appendRenameComments(
|
||||
baseScript,
|
||||
sqlRenamedTables,
|
||||
fieldRenames,
|
||||
finalDiagramForExport
|
||||
);
|
||||
}
|
||||
|
||||
standard = fixArrayTypes(
|
||||
normalizeCharTypeFormat(
|
||||
fixMultilineTableNames(
|
||||
@@ -1006,10 +937,13 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
|
||||
);
|
||||
|
||||
// Restore schema information that may have been stripped by DBML importer
|
||||
standard = restoreTableSchemas(standard, uniqueTables);
|
||||
standard = restoreTableSchemas(standard, tablesWithFields);
|
||||
|
||||
// Restore composite primary key names
|
||||
standard = restoreCompositePKNames(standard, uniqueTables);
|
||||
standard = restoreCompositePKNames(standard, tablesWithFields);
|
||||
|
||||
// Restore increment attribute for auto-incrementing fields
|
||||
standard = restoreIncrementAttribute(standard, tablesWithFields);
|
||||
|
||||
// Prepend Enum DBML to the standard output
|
||||
if (enumsDBML) {
|
||||
@@ -1054,5 +988,13 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
|
||||
}
|
||||
}
|
||||
|
||||
return { standardDbml: standard, inlineDbml: inline, error: errorMsg };
|
||||
// Extract relationships DBML from standard output
|
||||
const relationshipsDbml = extractRelationshipsDbml(standard);
|
||||
|
||||
return {
|
||||
standardDbml: standard,
|
||||
inlineDbml: inline,
|
||||
relationshipsDbml,
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
|
||||
3
src/lib/dbml/dbml-import/__tests__/cases/1.dbml
Normal file
3
src/lib/dbml/dbml-import/__tests__/cases/1.dbml
Normal file
@@ -0,0 +1,3 @@
|
||||
Table "public"."table_3"{
|
||||
"id" bigint [pk]
|
||||
}
|
||||
1
src/lib/dbml/dbml-import/__tests__/cases/1.json
Normal file
1
src/lib/dbml/dbml-import/__tests__/cases/1.json
Normal file
@@ -0,0 +1 @@
|
||||
{"id":"mqqwkkodxt6p","name":"Diagram 3","createdAt":"2025-09-16T15:33:25.300Z","updatedAt":"2025-09-16T15:33:31.563Z","databaseType":"postgresql","tables":[{"id":"loyxg6mafzos5u971uirjs3zh","name":"table_3","schema":"","order":0,"fields":[{"id":"29e2p9bom0uxo1n0a9ze5auuy","name":"id","type":{"name":"bigint","id":"bigint","usageLevel":2},"nullable":true,"primaryKey":true,"unique":true,"createdAt":1758036805300}],"indexes":[{"id":"5gf0aeptch1uk1bxv0x89wxxe","name":"pk_table_3_id","fieldIds":["29e2p9bom0uxo1n0a9ze5auuy"],"unique":true,"isPrimaryKey":true,"createdAt":1758036811564}],"x":0,"y":0,"color":"#8eb7ff","isView":false,"createdAt":1758036805300,"diagramId":"mqqwkkodxt6p"}],"relationships":[],"dependencies":[],"areas":[],"customTypes":[]}
|
||||
7
src/lib/dbml/dbml-import/__tests__/cases/2.dbml
Normal file
7
src/lib/dbml/dbml-import/__tests__/cases/2.dbml
Normal file
@@ -0,0 +1,7 @@
|
||||
Table "table_3" {
|
||||
"id" bigint [pk]
|
||||
}
|
||||
|
||||
Table "table_2" {
|
||||
"id" bigint [pk, not null, ref: < "table_3"."id"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user