Compare commits

...

73 Commits

Author SHA1 Message Date
johnnyfish
d1fbab31bc fix build 2025-08-04 20:33:09 +03:00
johnnyfish
4dd2a5742e fix: allow hiding all schemas and add filtered tables message in side panel 2025-08-04 20:33:09 +03:00
johnnyfish
fb54d73465 refactor: consolidate table filtering to canvas filter with improved UX 2025-08-04 20:33:09 +03:00
Guy Ben-Aharon
16f9f4671e fix(dbml): dbml indentation (#829) 2025-08-04 20:32:48 +03:00
Guy Ben-Aharon
0c300e5e72 fix(dbml): fix schemas with same table names (#828) 2025-08-04 17:50:28 +03:00
Guy Ben-Aharon
b9a1e78b53 fix(dbml): import dbml notes (table + fields) (#827) 2025-08-04 12:43:02 +03:00
Guy Ben-Aharon
337f7cdab4 fix(dbml): dbml note syntax (#826) 2025-08-04 12:21:37 +03:00
Guy Ben-Aharon
1b0390f0b7 feat(dbml): Edit Diagram Directly from DBML (#819)
* initial dbml apply

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix
2025-08-04 11:35:34 +03:00
Guy Ben-Aharon
bc52933b58 fix: update relationship when table width changes via expand/shrink (#825) 2025-08-03 22:41:11 +03:00
Guy Ben-Aharon
2fdad2344c solve issue with multiple render of tables (#824) 2025-08-03 22:13:18 +03:00
Guy Ben-Aharon
0c7eaa2df2 fix: solve issue with multiple render of tables (#823) 2025-08-03 21:52:17 +03:00
Guy Ben-Aharon
a5f8e56b3c fix(dbml): support multiple relationships on same field in inline DBML (#822) 2025-08-03 12:04:05 +03:00
Guy Ben-Aharon
8ffde62c1a fix(readonly): fix zoom out on readonly (#818) 2025-07-31 15:27:38 +03:00
Guy Ben-Aharon
39247b77a2 feat: enhance primary key and unique field handling logic (#817) 2025-07-31 11:38:33 +03:00
Guy Ben-Aharon
984b2aeee2 fix(ui): reduce spacing between primary key icon and short field types (#816) 2025-07-31 11:04:48 +03:00
Guy Ben-Aharon
eed104be5b fix(dbml): fix dbml output format (#815) 2025-07-30 14:31:56 +03:00
Guy Ben-Aharon
00bd535b3c fix(dbml import): fix dbml import types + schemas (#808)
* fix(dbml import): fix dbml import types + schemas

* fix(dbml import): fix dbml import types + schemas

* fix(dbml import): fix dbml import types + schemas

* fix
2025-07-29 17:55:29 +03:00
Guy Ben-Aharon
18e914242f fix(dbml export): fix handle tables with same name under different schemas (#807) 2025-07-29 16:22:09 +03:00
Guy Ben-Aharon
e68837a34a fix(dbml export): handle tables with same name under different schemas (#806) 2025-07-29 14:59:08 +03:00
Guy Ben-Aharon
b30162d98b fix: clone of custom types (#804) 2025-07-29 12:49:28 +03:00
Guy Ben-Aharon
dba372d25a fix(cockroachdb): support schema creation for cockroachdb (#803) 2025-07-28 18:55:05 +03:00
Jonathan Fishner
2eb48e75d3 fix(i18n): add Croatian (hr) language support (#802)
* feat: add Croatian (hr) language support

* fix translation

* fix: change langs order

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-28 18:12:17 +03:00
Guy Ben-Aharon
867903cd5f feat(schema): support create new schema (#801)
* feat(schema): support create new schema

* fix

* fix
2025-07-28 17:32:28 +03:00
Guy Ben-Aharon
8aeb1df0ad fix: fix screen freeze after schema select (#800)
* fix: fix screen freeze after schema select

* fix: fix screen freeze after schema select
2025-07-28 12:00:34 +03:00
Guy Ben-Aharon
6bea827293 fix(canvas filter): improve scroller on canvas filter (#799) 2025-07-28 11:48:45 +03:00
Guy Ben-Aharon
a119854da7 fix(dbml actions): set dbml tooltips side (#798) 2025-07-28 10:22:35 +03:00
Jonathan Fishner
bfbfd7b843 fix(dbml editor): move tooltips button to be on the right (#797) 2025-07-27 23:11:34 +03:00
Guy Ben-Aharon
0ca7008735 fix(dbml field comments): support export field comments in dbml (#796)
* fix(dbml field comments): support export field comments in dbml

* add tests
2025-07-27 20:53:55 +03:00
Guy Ben-Aharon
4bc71c52ff fix(scroll): disable scroll x behavior (#795) 2025-07-27 20:15:04 +03:00
Guy Ben-Aharon
8f27f10dec fix(dbml): support spaces in names (#794) 2025-07-27 19:44:43 +03:00
Guy Ben-Aharon
a93ec2cab9 fix: lost in canvas button animation (#793) 2025-07-27 17:34:48 +03:00
Jonathan Fishner
386e40a0bf fix: update MariaDB database import smart query (#792) 2025-07-27 16:29:16 +03:00
Jonathan Fishner
bda150d4b6 feat: add floating "Show All" button when tables are out of view (#787)
* feat: add floating "Show All" button when tables are out of view

* fix view of show all for mobile

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-27 16:25:14 +03:00
Guy Ben-Aharon
87836e53d1 fix: remove unnecessary import (#791) 2025-07-27 12:29:19 +03:00
Jonathan Fishner
7e0483f1a5 feat(custom-types): add highlight fields option for custom types (#726)
* feat(custom-types): add highlight feilds option for custom types

* fix(custom-types): show indicator when custom type is in used

* feat(canvas): add enum highlight indicator with pulse animation and double-click to clear

* some fixes

* some fixes

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-27 12:17:57 +03:00
Jonathan Fishner
309ee9cb0f fix(dbml-export): merge field attributes into single brackets and fix schema syntax (#790)
* fix(dbml-export): merge field attributes into single brackets and fix schema syntax

* fix build
2025-07-26 22:03:02 +03:00
Jonathan Fishner
79b885502e fix(sql-server): improvment for sql-server import via sql script (#789)
* feat: improvment for sql-server import via sql script

* fix for test

* some fixes

* some fixes

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-25 19:16:35 +03:00
Guy Ben-Aharon
745bdee86d fix(table-node): fix comment icon on field (#786) 2025-07-24 17:27:23 +03:00
Guy Ben-Aharon
08eb9cc55f fix(table-node): improve field spacing (#785) 2025-07-24 16:41:45 +03:00
Jonathan Fishner
778f85d492 feat(datatypes): Add decimal / numeric attribute support + organize field row (#715)
* added decimal scale and precision support

* update i18n

* added button to reset - made values always enabled in pairs

* made button use ml

* added fix for when manually defined scales are set to 0

* fix

* some fixes

* some fixes

* some fixes

* some fixes

* some fixes

---------

Co-authored-by: Alexander Harris <mcalapurge@techie.com>
Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-24 15:18:33 +03:00
Guy Ben-Aharon
fb92be7d3e alignment sql export scripts (#784) 2025-07-23 21:00:52 +03:00
Jonathan Fishner
6df588f40e fix: improve SQL export formatting and add schema-aware FK grouping (#783)
* fix: correct foreign key direction based on relationship cardinality in SQL exports

* fix: improve SQL export formatting and add schema-aware FK grouping

* fix build

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-23 18:05:59 +03:00
Guy Ben-Aharon
b46ed58dff fix(table-select): add loading indication for import (#782) 2025-07-23 15:53:26 +03:00
Jonathan Fishner
0d9f57a9c9 feat: add table selection for large database imports (#776)
* feat: add table selection UI for large database imports (>50 tables)

* some changes

* some changes

* some changes

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-23 11:35:27 +03:00
Guy Ben-Aharon
b7dbe54c83 fix(canvas): fix filter eye button (#780) 2025-07-21 19:01:52 +03:00
Guy Ben-Aharon
43d1dfff71 fix: fix hotkeys on form elements (#778) 2025-07-21 17:31:42 +03:00
Guy Ben-Aharon
9949a46ee3 fix: set focus on filter search (#775) 2025-07-21 16:18:18 +03:00
Guy Ben-Aharon
dfbcf05b2f feat(canvas): Add filter tables on canvas (#774)
* feat(canvas): filter tables on canvas

* fix build

* fix

* fix
2025-07-21 15:54:27 +03:00
Jonathan Fishner
f56fab9876 fix: update multiple schemas toast to require user action (#771)
* fix: update multiple schemas toast to require user action

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-21 12:43:57 +03:00
Jonathan Fishner
c9ea7da092 feat(default value): add default value option to table field settings (#770)
* feat: add default value option to table field settings

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-21 12:29:53 +03:00
Jonathan Fishner
22d46e1e90 fix(dbml-import): handle unsupported DBML features and add comprehensive tests (#766)
* fix(dbml-import): handle unsupported DBML features and add comprehensive tests

* fix build

* fix(dbml-export): handle composite primary keys, invalid defaults, and char type formatting

* fix build

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-21 11:33:34 +03:00
Guy Ben-Aharon
6af94afc56 fix(area): redo/undo after dragging an area with tables (#767) 2025-07-17 16:49:05 +03:00
Jonathan Fishner
f7f92903de fix(sql-export): escape newlines and quotes in multi-line comments (#765) 2025-07-16 15:18:52 +03:00
Jonathan Fishner
b35e17526b feat: implement area grouping with parent-child relationships (#762)
* feat: implement area grouping with parent-child relationships

* fix: improve area node visual appearance and text visibility

* update area header color

* fix build

* fix

* fix

* fix

* fix

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-16 15:11:16 +03:00
Guy Ben-Aharon
bf32c08d37 fix: remove error lag after autofix (#764) 2025-07-14 21:27:05 +03:00
Jonathan Fishner
5d337409d6 fix: add PostgreSQL tests and fix parsing SQL (#760)
* feat: add PostgreSQL tests and fix parsing SQL

* fix: disable format on paste for SQL DDL import

* some ui fixes + add tests to the ci

* fix

* fix validator and importer

* fix for maria-db

* fix

* remove improved

* fix

* fix

* fix

* fix for test

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-14 19:25:44 +03:00
Guy Ben-Aharon
67f5ac303e fix: add open and create diagram to side menu (#757) 2025-07-08 13:28:44 +03:00
Guy Ben-Aharon
578546a171 chore(main): release 1.13.2 (#756) 2025-07-06 14:02:21 +03:00
Jonathan Fishner
aa0b629a3e fix: add DISABLE_ANALYTICS flag to opt-out of Fathom analytics (#750)
* feat: add DISABLE_ANALYTICS flag to opt-out of Fathom analytics

* fix: HIDE_BUCKLE_DOT_DEV -> HIDE_CHARTDB_CLOUD

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-07-06 13:52:58 +03:00
Guy Ben-Aharon
69beaa0a83 chore(main): release 1.13.1 (#735) 2025-07-05 18:14:47 +03:00
Guy Ben-Aharon
4fcc49d49a fix: general performance improvements on canvas (#751) 2025-07-04 12:23:29 +03:00
Guy Ben-Aharon
d15985e399 fix: resolve unresponsive cursor and input glitches when editing field comments (#749) 2025-07-02 20:33:52 +03:00
Jonathan Fishner
d429128e65 fix(dbml): Filter duplicate tables at diagram level before export dbml (#746) 2025-06-24 12:46:56 +03:00
Jonathan Fishner
2fce8326b6 fix(import-database): for custom types query to import supabase & timescale (#745) 2025-06-11 21:50:42 +03:00
Guy Ben-Aharon
433c68a33d lib refactor (#744) 2025-06-10 11:04:51 +03:00
Guy Ben-Aharon
58acb65f12 fix duplicate (#743)
* fix duplicate

* fix duplicate
2025-06-08 15:27:50 +03:00
Guy Ben-Aharon
7978955819 lib fix (#742) 2025-06-08 14:44:45 +03:00
Jonathan Fishner
c6118e0cdb fix(export-sql): conditionally show generic option and reorder by diagram type (#708)
* fix(export-sql): conditionally show generic option and reorder by diagram type

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-06-08 12:32:05 +03:00
Jonathan Fishner
7d063b905f fix(import-db): fix mariadb import (#740) 2025-06-06 19:45:13 +03:00
Jonathan Fishner
e0ff198c3f fix(dbml-editor): for some cases that the dbml had issues (#739) 2025-06-06 19:41:51 +03:00
Vlad Kovaliov
8b86e1c229 fix(table name): updates table name value when its updated from canvas/sidebar (#716) 2025-06-05 15:36:59 +03:00
Guy Ben-Aharon
24be28a662 fix(custom_types): fix display custom types in select box (#737) 2025-06-05 15:21:55 +03:00
Guy Ben-Aharon
c6788b4917 fix(performance): improve storage provider performance (#734)
* fix(performance): improve storage provider performance

* fix
2025-06-04 11:22:33 +03:00
213 changed files with 29716 additions and 5507 deletions

View File

@@ -24,4 +24,7 @@ jobs:
run: npm run lint
- name: Build
run: npm run build
run: npm run build
- name: Run tests
run: npm run test:ci

View File

@@ -1,5 +1,28 @@
# Changelog
## [1.13.2](https://github.com/chartdb/chartdb/compare/v1.13.1...v1.13.2) (2025-07-06)
### Bug Fixes
* add DISABLE_ANALYTICS flag to opt-out of Fathom analytics ([#750](https://github.com/chartdb/chartdb/issues/750)) ([aa0b629](https://github.com/chartdb/chartdb/commit/aa0b629a3eaf8e8b60473ea3f28f769270c7714c))
## [1.13.1](https://github.com/chartdb/chartdb/compare/v1.13.0...v1.13.1) (2025-07-04)
### Bug Fixes
* **custom_types:** fix display custom types in select box ([#737](https://github.com/chartdb/chartdb/issues/737)) ([24be28a](https://github.com/chartdb/chartdb/commit/24be28a662c48fc5bc62e76446b9669d83d7d3e0))
* **dbml-editor:** for some cases that the dbml had issues ([#739](https://github.com/chartdb/chartdb/issues/739)) ([e0ff198](https://github.com/chartdb/chartdb/commit/e0ff198c3fd416498dac5680bb323ec88c54b65c))
* **dbml:** Filter duplicate tables at diagram level before export dbml ([#746](https://github.com/chartdb/chartdb/issues/746)) ([d429128](https://github.com/chartdb/chartdb/commit/d429128e65aa28c500eac2487356e4869506e948))
* **export-sql:** conditionally show generic option and reorder by diagram type ([#708](https://github.com/chartdb/chartdb/issues/708)) ([c6118e0](https://github.com/chartdb/chartdb/commit/c6118e0cdb0e5caaf73447d33db2fde1a98efe60))
* general performance improvements on canvas ([#751](https://github.com/chartdb/chartdb/issues/751)) ([4fcc49d](https://github.com/chartdb/chartdb/commit/4fcc49d49a76a4b886ffd6cf0b40cf2fc49952ec))
* **import-database:** for custom types query to import supabase & timescale ([#745](https://github.com/chartdb/chartdb/issues/745)) ([2fce832](https://github.com/chartdb/chartdb/commit/2fce8326b67b751d38dd34f409fea574449d0298))
* **import-db:** fix mariadb import ([#740](https://github.com/chartdb/chartdb/issues/740)) ([7d063b9](https://github.com/chartdb/chartdb/commit/7d063b905f19f51501468bd0bd794a25cf65e1be))
* **performance:** improve storage provider performance ([#734](https://github.com/chartdb/chartdb/issues/734)) ([c6788b4](https://github.com/chartdb/chartdb/commit/c6788b49173d9cce23571daeb460285cb7cffb11))
* resolve unresponsive cursor and input glitches when editing field comments ([#749](https://github.com/chartdb/chartdb/issues/749)) ([d15985e](https://github.com/chartdb/chartdb/commit/d15985e3999a0cd54213b2fb08c55d48a1b8b3b2))
* **table name:** updates table name value when its updated from canvas/sidebar ([#716](https://github.com/chartdb/chartdb/issues/716)) ([8b86e1c](https://github.com/chartdb/chartdb/commit/8b86e1c22992aaadcce7ad5fc1d267c5a57a99f0))
## [1.13.0](https://github.com/chartdb/chartdb/compare/v1.12.0...v1.13.0) (2025-05-28)

View File

@@ -3,7 +3,8 @@ FROM node:22-alpine AS builder
ARG VITE_OPENAI_API_KEY
ARG VITE_OPENAI_API_ENDPOINT
ARG VITE_LLM_MODEL_NAME
ARG VITE_HIDE_BUCKLE_DOT_DEV
ARG VITE_HIDE_CHARTDB_CLOUD
ARG VITE_DISABLE_ANALYTICS
WORKDIR /usr/src/app
@@ -16,7 +17,8 @@ COPY . .
RUN echo "VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}" > .env && \
echo "VITE_OPENAI_API_ENDPOINT=${VITE_OPENAI_API_ENDPOINT}" >> .env && \
echo "VITE_LLM_MODEL_NAME=${VITE_LLM_MODEL_NAME}" >> .env && \
echo "VITE_HIDE_BUCKLE_DOT_DEV=${VITE_HIDE_BUCKLE_DOT_DEV}" >> .env
echo "VITE_HIDE_CHARTDB_CLOUD=${VITE_HIDE_CHARTDB_CLOUD}" >> .env && \
echo "VITE_DISABLE_ANALYTICS=${VITE_DISABLE_ANALYTICS}" >> .env
RUN npm run build

View File

@@ -125,6 +125,8 @@ docker run \
-p 8080:80 chartdb
```
> **Privacy Note:** ChartDB includes privacy-focused analytics via Fathom Analytics. You can disable this by adding `-e DISABLE_ANALYTICS=true` to the run command or `--build-arg VITE_DISABLE_ANALYTICS=true` when building.
> **Note:** You must configure either Option 1 (OpenAI API key) OR Option 2 (Custom endpoint and model name) for AI capabilities to work. Do not mix the two options.
Open your browser and navigate to `http://localhost:8080`.

View File

@@ -10,11 +10,12 @@ server {
location /config.js {
default_type application/javascript;
return 200 "window.env = {
return 200 "window.env = {
OPENAI_API_KEY: \"$OPENAI_API_KEY\",
OPENAI_API_ENDPOINT: \"$OPENAI_API_ENDPOINT\",
LLM_MODEL_NAME: \"$LLM_MODEL_NAME\",
HIDE_BUCKLE_DOT_DEV: \"$HIDE_BUCKLE_DOT_DEV\"
HIDE_CHARTDB_CLOUD: \"$HIDE_CHARTDB_CLOUD\",
DISABLE_ANALYTICS: \"$DISABLE_ANALYTICS\"
};";
}

View File

@@ -1,7 +1,7 @@
#!/bin/sh
# Replace placeholders in nginx.conf
envsubst '${OPENAI_API_KEY} ${OPENAI_API_ENDPOINT} ${LLM_MODEL_NAME} ${HIDE_BUCKLE_DOT_DEV}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
envsubst '${OPENAI_API_KEY} ${OPENAI_API_ENDPOINT} ${LLM_MODEL_NAME} ${HIDE_CHARTDB_CLOUD} ${DISABLE_ANALYTICS}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
# Start Nginx
nginx -g "daemon off;"

View File

@@ -13,11 +13,21 @@
rel="stylesheet"
/>
<script src="/config.js"></script>
<script
src="https://cdn.usefathom.com/script.js"
data-site="PRHIVBNN"
defer
></script>
<script>
// Load analytics only if not disabled
(function() {
const disableAnalytics = (window.env && window.env.DISABLE_ANALYTICS === 'true') ||
(typeof process !== 'undefined' && process.env && process.env.VITE_DISABLE_ANALYTICS === 'true');
if (!disableAnalytics) {
const script = document.createElement('script');
script.src = 'https://cdn.usefathom.com/script.js';
script.setAttribute('data-site', 'PRHIVBNN');
script.defer = true;
document.head.appendChild(script);
}
})();
</script>
</head>
<body>
<div id="root"></div>

1070
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "chartdb",
"private": true,
"version": "1.13.0",
"version": "1.13.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,7 +9,11 @@
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"preview": "vite preview",
"prepare": "husky"
"prepare": "husky",
"test": "vitest",
"test:ci": "vitest run --reporter=verbose --bail=1",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
"dependencies": {
"@ai-sdk/openai": "^0.0.51",
@@ -32,14 +36,14 @@
"@radix-ui/react-scroll-area": "1.2.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.8",
"@uidotdev/usehooks": "^2.4.1",
"@xyflow/react": "^12.3.1",
"@xyflow/react": "^12.8.2",
"ahooks": "^3.8.1",
"ai": "^3.3.14",
"class-variance-authority": "^0.7.1",
@@ -50,8 +54,9 @@
"html-to-image": "^1.11.11",
"i18next": "^23.14.0",
"i18next-browser-languagedetector": "^8.0.0",
"lucide-react": "^0.441.0",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.0",
"motion": "^12.23.6",
"nanoid": "^5.0.7",
"node-sql-parser": "^5.3.2",
"react": "^18.3.1",
@@ -73,12 +78,16 @@
"@eslint/compat": "^1.2.4",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.16.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.1.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
@@ -90,6 +99,7 @@
"eslint-plugin-react-refresh": "^0.4.7",
"eslint-plugin-tailwindcss": "^3.17.4",
"globals": "^15.13.0",
"happy-dom": "^18.0.1",
"husky": "^9.1.5",
"postcss": "^8.4.40",
"prettier": "^3.3.3",
@@ -97,6 +107,7 @@
"tailwindcss": "^3.4.7",
"typescript": "^5.2.2",
"unplugin-inject-preload": "^3.0.0",
"vite": "^5.3.4"
"vite": "^5.3.4",
"vitest": "^3.2.4"
}
}

View File

@@ -31,6 +31,7 @@ export interface CodeSnippetAction {
label: string;
icon: LucideIcon;
onClick: () => void;
className?: string;
}
export interface CodeSnippetProps {
@@ -43,6 +44,8 @@ export interface CodeSnippetProps {
isComplete?: boolean;
editorProps?: React.ComponentProps<EditorType>;
actions?: CodeSnippetAction[];
actionsTooltipSide?: 'top' | 'right' | 'bottom' | 'left';
allowCopy?: boolean;
}
export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo(
@@ -56,6 +59,8 @@ export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo(
isComplete = true,
editorProps,
actions,
actionsTooltipSide,
allowCopy = true,
}) => {
const { t } = useTranslation();
const monaco = useMonaco();
@@ -129,33 +134,37 @@ export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo(
<Suspense fallback={<Spinner />}>
{isComplete ? (
<div className="absolute right-1 top-1 z-10 flex flex-col gap-1">
<Tooltip
onOpenChange={setTooltipOpen}
open={isCopied || tooltipOpen}
>
<TooltipTrigger asChild>
<span>
<Button
className="h-fit p-1.5"
variant="outline"
onClick={copyToClipboard}
>
{isCopied ? (
<CopyCheck size={16} />
) : (
<Copy size={16} />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{t(
isCopied
? 'copied'
: 'copy_to_clipboard'
)}
</TooltipContent>
</Tooltip>
{allowCopy ? (
<Tooltip
onOpenChange={setTooltipOpen}
open={isCopied || tooltipOpen}
>
<TooltipTrigger asChild>
<span>
<Button
className="h-fit p-1.5"
variant="outline"
onClick={copyToClipboard}
>
{isCopied ? (
<CopyCheck size={16} />
) : (
<Copy size={16} />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent
side={actionsTooltipSide}
>
{t(
isCopied
? 'copied'
: 'copy_to_clipboard'
)}
</TooltipContent>
</Tooltip>
) : null}
{actions &&
actions.length > 0 &&
@@ -164,7 +173,10 @@ export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo(
<TooltipTrigger asChild>
<span>
<Button
className="h-fit p-1.5"
className={cn(
'h-fit p-1.5',
action.className
)}
variant="outline"
onClick={action.onClick}
>
@@ -174,7 +186,9 @@ export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo(
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<TooltipContent
side={actionsTooltipSide}
>
{action.label}
</TooltipContent>
</Tooltip>

View File

@@ -0,0 +1,51 @@
import type { DBMLError } from '@/lib/dbml/dbml-import/dbml-import-error';
import * as monaco from 'monaco-editor';
export const highlightErrorLine = ({
error,
model,
editorDecorationsCollection,
}: {
error: DBMLError;
model?: monaco.editor.ITextModel | null;
editorDecorationsCollection:
| monaco.editor.IEditorDecorationsCollection
| undefined;
}) => {
if (!model) return;
if (!editorDecorationsCollection) return;
const decorations = [
{
range: new monaco.Range(
error.line,
1,
error.line,
model.getLineMaxColumn(error.line)
),
options: {
isWholeLine: true,
className: 'dbml-error-line',
glyphMarginClassName: 'dbml-error-glyph',
hoverMessage: { value: error.message },
overviewRuler: {
color: '#ff0000',
position: monaco.editor.OverviewRulerLane.Right,
darkColor: '#ff0000',
},
},
},
];
editorDecorationsCollection?.set(decorations);
};
export const clearErrorHighlight = (
editorDecorationsCollection:
| monaco.editor.IEditorDecorationsCollection
| undefined
) => {
if (editorDecorationsCollection) {
editorDecorationsCollection.clear();
}
};

View File

@@ -37,18 +37,28 @@ export const setupDBMLLanguage = (monaco: Monaco) => {
const datatypePattern = dataTypesNames.join('|');
monaco.languages.setMonarchTokensProvider('dbml', {
keywords: ['Table', 'Ref', 'Indexes'],
keywords: ['Table', 'Ref', 'Indexes', 'Note', 'Enum'],
datatypes: dataTypesNames,
tokenizer: {
root: [
[/\b(Table|Ref|Indexes)\b/, 'keyword'],
[
/\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',
],
[/\[.*?\]/, 'annotation'],
[/'''/, 'string', '@tripleQuoteString'],
[/".*?"/, 'string'],
[/'.*?'/, 'string'],
[/`.*?`/, 'string'],
[/[{}]/, 'delimiter'],
[/[<>]/, 'operator'],
[new RegExp(`\\b(${datatypePattern})\\b`, 'i'), 'type'], // Added 'i' flag for case-insensitive matching
],
tripleQuoteString: [
[/[^']+/, 'string'],
[/'''/, 'string', '@pop'],
[/'/, 'string'],
],
},
});
};

View File

@@ -52,7 +52,7 @@ export const EmptyState = forwardRef<
</Label>
<Label
className={cn(
'text-sm font-normal text-muted-foreground',
'text-sm text-center font-normal text-muted-foreground',
descriptionClassName
)}
>

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { cn } from '@/lib/utils';
import type { ButtonProps } from '../button/button';
import { buttonVariants } from '../button/button-variants';
import {
ChevronLeftIcon,
ChevronRightIcon,
DotsHorizontalIcon,
} from '@radix-ui/react-icons';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
));
PaginationContent.displayName = 'PaginationContent';
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
));
PaginationItem.displayName = 'PaginationItem';
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>;
const PaginationLink = ({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className
)}
{...props}
/>
);
PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 pl-2.5', className)}
{...props}
>
<ChevronLeftIcon className="size-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 pr-2.5', className)}
{...props}
>
<span>Next</span>
<ChevronRightIcon className="size-4" />
</PaginationLink>
);
PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<DotsHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@@ -93,6 +93,8 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
(isOpen: boolean) => {
setOpen?.(isOpen);
setIsOpen(isOpen);
setTimeout(() => (document.body.style.pointerEvents = ''), 500);
},
[setOpen]
);
@@ -227,7 +229,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
onSelect={() =>
handleSelect(
option.value,
matches?.map((match) => match.toString())
matches?.map((match) => match?.toString())
)
}
>
@@ -418,27 +420,22 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>(
<ScrollArea>
<div className="max-h-64 w-full">
<CommandGroup>
<CommandList className="max-h-fit w-full">
{hasGroups
? Object.entries(groups).map(
([
groupName,
groupOptions,
]) => (
<CommandGroup
key={groupName}
heading={groupName}
>
{groupOptions.map(
renderOption
)}
</CommandGroup>
)
<CommandList className="max-h-fit w-full">
{hasGroups
? Object.entries(groups).map(
([groupName, groupOptions]) => (
<CommandGroup
key={groupName}
heading={groupName}
>
{groupOptions.map(
renderOption
)}
</CommandGroup>
)
: options.map(renderOption)}
</CommandList>
</CommandGroup>
)
: options.map(renderOption)}
</CommandList>
</div>
</ScrollArea>
</Command>

View File

@@ -20,6 +20,7 @@ export function Toaster() {
description,
action,
layout = 'row',
hideCloseButton = false,
...props
}) {
return (
@@ -38,7 +39,7 @@ export function Toaster() {
) : null}
</div>
{layout === 'row' ? action : null}
<ToastClose />
{!hideCloseButton ? <ToastClose /> : null}
</Toast>
);
})}

View File

@@ -12,6 +12,7 @@ type ToasterToast = ToastProps & {
description?: React.ReactNode;
action?: ToastActionElement;
layout?: 'row' | 'column';
hideCloseButton?: boolean;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Skeleton } from '../skeleton/skeleton';
import { cn } from '@/lib/utils';
export interface TreeItemSkeletonProps
extends React.HTMLAttributes<HTMLDivElement> {}
export const TreeItemSkeleton: React.FC<TreeItemSkeletonProps> = ({
className,
style,
}) => {
return (
<div className={cn('px-2 py-1', className)} style={style}>
<Skeleton className="h-3.5 w-full rounded-sm" />
</div>
);
};

View File

@@ -0,0 +1,461 @@
import {
ChevronRight,
File,
Folder,
Loader2,
type LucideIcon,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Button } from '@/components/button/button';
import type {
TreeNode,
FetchChildrenFunction,
SelectableTreeProps,
} from './tree';
import type { ExpandedState } from './use-tree';
import { useTree } from './use-tree';
import type { Dispatch, ReactNode, SetStateAction } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { TreeItemSkeleton } from './tree-item-skeleton';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip/tooltip';
interface TreeViewProps<
Type extends string,
Context extends Record<Type, unknown>,
> {
data: TreeNode<Type, Context>[];
fetchChildren?: FetchChildrenFunction<Type, Context>;
onNodeClick?: (node: TreeNode<Type, Context>) => void;
className?: string;
defaultIcon?: LucideIcon;
defaultFolderIcon?: LucideIcon;
defaultIconProps?: React.ComponentProps<LucideIcon>;
defaultFolderIconProps?: React.ComponentProps<LucideIcon>;
selectable?: SelectableTreeProps<Type, Context>;
expanded?: ExpandedState;
setExpanded?: Dispatch<SetStateAction<ExpandedState>>;
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
loadingNodeIds?: string[];
}
export function TreeView<
Type extends string,
Context extends Record<Type, unknown>,
>({
data,
fetchChildren,
onNodeClick,
className,
defaultIcon = File,
defaultFolderIcon = Folder,
defaultIconProps,
defaultFolderIconProps,
selectable,
expanded: expandedProp,
setExpanded: setExpandedProp,
renderHoverComponent,
renderActionsComponent,
loadingNodeIds,
}: TreeViewProps<Type, Context>) {
const { expanded, loading, loadedChildren, hasMoreChildren, toggleNode } =
useTree({
fetchChildren,
expanded: expandedProp,
setExpanded: setExpandedProp,
});
const [selectedIdInternal, setSelectedIdInternal] = React.useState<
string | undefined
>(selectable?.defaultSelectedId);
const selectedId = useMemo(() => {
return selectable?.selectedId ?? selectedIdInternal;
}, [selectable?.selectedId, selectedIdInternal]);
const setSelectedId = useCallback(
(value: SetStateAction<string | undefined>) => {
if (selectable?.setSelectedId) {
selectable.setSelectedId(value);
} else {
setSelectedIdInternal(value);
}
},
[selectable, setSelectedIdInternal]
);
useEffect(() => {
if (selectable?.enabled && selectable.defaultSelectedId) {
if (selectable.defaultSelectedId === selectedId) return;
setSelectedId(selectable.defaultSelectedId);
const { node, path } = findNodeById(
data,
selectable.defaultSelectedId
);
if (node) {
selectable.onSelectedChange?.(node);
// Expand all parent nodes
for (const parent of path) {
if (expanded[parent.id]) continue;
toggleNode(
parent.id,
parent.type,
parent.context,
parent.children
);
}
}
}
}, [selectable, toggleNode, selectedId, data, expanded, setSelectedId]);
const handleNodeSelect = (node: TreeNode<Type, Context>) => {
if (selectable?.enabled) {
setSelectedId(node.id);
selectable.onSelectedChange?.(node);
}
};
return (
<div className={cn('w-full', className)}>
{data.map((node, index) => (
<TreeNode
key={node.id}
node={node}
level={0}
expanded={expanded}
loading={loading}
loadedChildren={loadedChildren}
hasMoreChildren={hasMoreChildren}
onToggle={toggleNode}
onNodeClick={onNodeClick}
defaultIcon={defaultIcon}
defaultFolderIcon={defaultFolderIcon}
defaultIconProps={defaultIconProps}
defaultFolderIconProps={defaultFolderIconProps}
selectable={selectable?.enabled}
selectedId={selectedId}
onSelect={handleNodeSelect}
className={index > 0 ? 'mt-0.5' : ''}
renderHoverComponent={renderHoverComponent}
renderActionsComponent={renderActionsComponent}
loadingNodeIds={loadingNodeIds}
/>
))}
</div>
);
}
interface TreeNodeProps<
Type extends string,
Context extends Record<Type, unknown>,
> {
node: TreeNode<Type, Context>;
level: number;
expanded: Record<string, boolean>;
loading: Record<string, boolean>;
loadedChildren: Record<string, TreeNode<Type, Context>[]>;
hasMoreChildren: Record<string, boolean>;
onToggle: (
nodeId: string,
nodeType: Type,
nodeContext: Context[Type],
staticChildren?: TreeNode<Type, Context>[]
) => void;
onNodeClick?: (node: TreeNode<Type, Context>) => void;
defaultIcon: LucideIcon;
defaultFolderIcon: LucideIcon;
defaultIconProps?: React.ComponentProps<LucideIcon>;
defaultFolderIconProps?: React.ComponentProps<LucideIcon>;
selectable?: boolean;
selectedId?: string;
onSelect: (node: TreeNode<Type, Context>) => void;
className?: string;
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
loadingNodeIds?: string[];
}
function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
node,
level,
expanded,
loading,
loadedChildren,
hasMoreChildren,
onToggle,
onNodeClick,
defaultIcon: DefaultIcon,
defaultFolderIcon: DefaultFolderIcon,
defaultIconProps,
defaultFolderIconProps,
selectable,
selectedId,
onSelect,
className,
renderHoverComponent,
renderActionsComponent,
loadingNodeIds,
}: 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;
const isSelected = selectedId === node.id;
const IconComponent =
node.icon || (node.isFolder ? DefaultFolderIcon : DefaultIcon);
const iconProps: React.ComponentProps<LucideIcon> = {
strokeWidth: isSelected ? 2.5 : 2,
...(node.isFolder ? defaultFolderIconProps : defaultIconProps),
...node.iconProps,
className: cn(
'h-3.5 w-3.5 text-muted-foreground flex-none',
isSelected && 'text-primary text-white',
node.iconProps?.className
),
};
return (
<div className={cn(className)}>
<div
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-lg cursor-pointer group h-6',
'transition-colors duration-200',
isSelected
? 'bg-sky-500 border border-sky-600 border dark:bg-sky-600 dark:border-sky-700'
: 'hover:bg-gray-200/50 border border-transparent dark:hover:bg-gray-700/50',
node.className
)}
{...(isSelected ? { 'data-selected': true } : {})}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={(e) => {
e.stopPropagation();
if (selectable && !node.unselectable) {
onSelect(node);
}
// if (node.isFolder) {
// onToggle(node.id, node.children);
// }
// called only once in case of double click
if (e.detail !== 2) {
onNodeClick?.(node);
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (node.isFolder) {
onToggle(
node.id,
node.type,
node.context,
node.children
);
}
}}
>
<div className="flex flex-none items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className={cn(
'h-3.5 w-3.5 p-0 hover:bg-transparent flex-none',
isExpanded && 'rotate-90',
'transition-transform duration-200'
)}
onClick={(e) => {
e.stopPropagation();
if (node.isFolder) {
onToggle(
node.id,
node.type,
node.context,
node.children
);
}
}}
>
{node.isFolder &&
(isLoading ? (
<Loader2
className={cn('size-3.5 animate-spin', {
'text-white': isSelected,
})}
/>
) : (
<ChevronRight
className={cn('size-3.5', {
'text-white': isSelected,
})}
strokeWidth={2}
/>
))}
</Button>
{node.tooltip ? (
<Tooltip>
<TooltipTrigger asChild>
{loadingNodeIds?.includes(node.id) ? (
<Loader2
className={cn('size-3.5 animate-spin', {
'text-white': isSelected,
})}
/>
) : (
<IconComponent
{...(isSelected
? { 'data-selected': true }
: {})}
{...iconProps}
/>
)}
</TooltipTrigger>
<TooltipContent
align="center"
className="max-w-[400px]"
>
{node.tooltip}
</TooltipContent>
</Tooltip>
) : node.empty ? null : loadingNodeIds?.includes(
node.id
) ? (
<Loader2
className={cn('size-3.5 animate-spin', {
// 'text-white': isSelected,
})}
/>
) : (
<IconComponent
{...(isSelected ? { 'data-selected': true } : {})}
{...iconProps}
/>
)}
</div>
<span
{...node.labelProps}
className={cn(
'text-xs truncate min-w-0 flex-1 w-0',
isSelected && 'font-medium text-primary text-white',
node.labelProps?.className
)}
{...(isSelected ? { 'data-selected': true } : {})}
>
{node.empty ? '' : node.name}
</span>
{renderActionsComponent && renderActionsComponent(node)}
{isHovered && renderHoverComponent
? renderHoverComponent(node)
: null}
</div>
<AnimatePresence initial={false}>
{isExpanded && children && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{
height: 'auto',
opacity: 1,
transition: {
height: {
duration: Math.min(
0.3 + children.length * 0.018,
0.7
),
ease: 'easeInOut',
},
opacity: {
duration: Math.min(
0.2 + children.length * 0.012,
0.4
),
ease: 'easeInOut',
},
},
}}
exit={{
height: 0,
opacity: 0,
transition: {
height: {
duration: Math.min(
0.2 + children.length * 0.01,
0.45
),
ease: 'easeInOut',
},
opacity: {
duration: 0.1,
ease: 'easeOut',
},
},
}}
style={{ overflow: 'hidden' }}
>
{children.map((child) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
expanded={expanded}
loading={loading}
loadedChildren={loadedChildren}
hasMoreChildren={hasMoreChildren}
onToggle={onToggle}
onNodeClick={onNodeClick}
defaultIcon={DefaultIcon}
defaultFolderIcon={DefaultFolderIcon}
defaultIconProps={defaultIconProps}
defaultFolderIconProps={defaultFolderIconProps}
selectable={selectable}
selectedId={selectedId}
onSelect={onSelect}
className="mt-0.5"
renderHoverComponent={renderHoverComponent}
renderActionsComponent={renderActionsComponent}
loadingNodeIds={loadingNodeIds}
/>
))}
{isLoading ? (
<TreeItemSkeleton
style={{
paddingLeft: `${level + 2 * 16 + 8}px`,
}}
/>
) : null}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function findNodeById<
Type extends string,
Context extends Record<Type, unknown>,
>(
nodes: TreeNode<Type, Context>[],
id: string,
initialPath: TreeNode<Type, Context>[] = []
): { node: TreeNode<Type, Context> | null; path: TreeNode<Type, Context>[] } {
const path: TreeNode<Type, Context>[] = [...initialPath];
for (const node of nodes) {
if (node.id === id) return { node, path };
if (node.children) {
const found = findNodeById(node.children, id, [...path, node]);
if (found.node) {
return found;
}
}
}
return { node: null, path };
}

View File

@@ -0,0 +1,41 @@
import type { LucideIcon } from 'lucide-react';
import type React from 'react';
export interface TreeNode<
Type extends string,
Context extends Record<Type, unknown>,
> {
id: string;
name: string;
isFolder?: boolean;
children?: TreeNode<Type, Context>[];
icon?: LucideIcon;
iconProps?: React.ComponentProps<LucideIcon>;
labelProps?: React.ComponentProps<'span'>;
type: Type;
unselectable?: boolean;
tooltip?: string;
context: Context[Type];
empty?: boolean;
className?: string;
}
export type FetchChildrenFunction<
Type extends string,
Context extends Record<Type, unknown>,
> = (
nodeId: string,
nodeType: Type,
nodeContext: Context[Type]
) => Promise<TreeNode<Type, Context>[]>;
export interface SelectableTreeProps<
Type extends string,
Context extends Record<Type, unknown>,
> {
enabled: boolean;
defaultSelectedId?: string;
onSelectedChange?: (node: TreeNode<Type, Context>) => void;
selectedId?: string;
setSelectedId?: React.Dispatch<React.SetStateAction<string | undefined>>;
}

View File

@@ -0,0 +1,153 @@
import type { Dispatch, SetStateAction } from 'react';
import { useState, useCallback, useMemo } from 'react';
import type { TreeNode, FetchChildrenFunction } from './tree';
export interface ExpandedState {
[key: string]: boolean;
}
interface LoadingState {
[key: string]: boolean;
}
interface LoadedChildren<
Type extends string,
Context extends Record<Type, unknown>,
> {
[key: string]: TreeNode<Type, Context>[];
}
interface HasMoreChildrenState {
[key: string]: boolean;
}
export function useTree<
Type extends string,
Context extends Record<Type, unknown>,
>({
fetchChildren,
expanded: expandedProp,
setExpanded: setExpandedProp,
}: {
fetchChildren?: FetchChildrenFunction<Type, Context>;
expanded?: ExpandedState;
setExpanded?: Dispatch<SetStateAction<ExpandedState>>;
}) {
const [expandedInternal, setExpandedInternal] = useState<ExpandedState>({});
const expanded = useMemo(
() => expandedProp ?? expandedInternal,
[expandedProp, expandedInternal]
);
const setExpanded = useCallback(
(value: SetStateAction<ExpandedState>) => {
if (setExpandedProp) {
setExpandedProp(value);
} else {
setExpandedInternal(value);
}
},
[setExpandedProp, setExpandedInternal]
);
const [loading, setLoading] = useState<LoadingState>({});
const [loadedChildren, setLoadedChildren] = useState<
LoadedChildren<Type, Context>
>({});
const [hasMoreChildren, setHasMoreChildren] =
useState<HasMoreChildrenState>({});
const mergeChildren = useCallback(
(
staticChildren: TreeNode<Type, Context>[] = [],
fetchedChildren: TreeNode<Type, Context>[] = []
) => {
const fetchedChildrenIds = new Set(
fetchedChildren.map((child) => child.id)
);
const uniqueStaticChildren = staticChildren.filter(
(child) => !fetchedChildrenIds.has(child.id)
);
return [...uniqueStaticChildren, ...fetchedChildren];
},
[]
);
const toggleNode = useCallback(
async (
nodeId: string,
nodeType: Type,
nodeContext: Context[Type],
staticChildren?: TreeNode<Type, Context>[]
) => {
if (expanded[nodeId]) {
// If we're collapsing, just update expanded state
setExpanded((prev) => ({ ...prev, [nodeId]: false }));
return;
}
// Get any previously fetched children
const previouslyFetchedChildren = loadedChildren[nodeId] || [];
// If we have static children, merge them with any previously fetched children
if (staticChildren?.length) {
const mergedChildren = mergeChildren(
staticChildren,
previouslyFetchedChildren
);
setLoadedChildren((prev) => ({
...prev,
[nodeId]: mergedChildren,
}));
// Only show "more loading" if we haven't fetched children before
setHasMoreChildren((prev) => ({
...prev,
[nodeId]: !previouslyFetchedChildren.length,
}));
}
// 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) {
setLoading((prev) => ({ ...prev, [nodeId]: true }));
try {
const fetchedChildren = await fetchChildren?.(
nodeId,
nodeType,
nodeContext
);
// Merge static and newly fetched children
const allChildren = mergeChildren(
staticChildren || [],
fetchedChildren
);
setLoadedChildren((prev) => ({
...prev,
[nodeId]: allChildren,
}));
setHasMoreChildren((prev) => ({
...prev,
[nodeId]: false,
}));
} catch (error) {
console.error('Error loading children:', error);
} finally {
setLoading((prev) => ({ ...prev, [nodeId]: false }));
}
}
},
[expanded, loadedChildren, fetchChildren, mergeChildren, setExpanded]
);
return {
expanded,
loading,
loadedChildren,
hasMoreChildren,
toggleNode,
};
}

View File

@@ -12,6 +12,8 @@ export interface CanvasContext {
}) => void;
setOverlapGraph: (graph: Graph<string>) => void;
overlapGraph: Graph<string>;
setShowFilter: React.Dispatch<React.SetStateAction<boolean>>;
showFilter: boolean;
}
export const canvasContext = createContext<CanvasContext>({
@@ -19,4 +21,6 @@ export const canvasContext = createContext<CanvasContext>({
fitView: emptyFn,
setOverlapGraph: emptyFn,
overlapGraph: createGraph(),
setShowFilter: emptyFn,
showFilter: false,
});

View File

@@ -1,4 +1,11 @@
import React, { type ReactNode, useCallback, useState } from 'react';
import React, {
type ReactNode,
useCallback,
useState,
useMemo,
useEffect,
useRef,
} from 'react';
import { canvasContext } from './canvas-context';
import { useChartDB } from '@/hooks/use-chartdb';
import {
@@ -15,12 +22,50 @@ interface CanvasProviderProps {
}
export const CanvasProvider = ({ children }: CanvasProviderProps) => {
const { tables, relationships, updateTablesState, filteredSchemas } =
useChartDB();
const {
tables,
relationships,
updateTablesState,
filteredSchemas,
hiddenTableIds,
schemas,
} = useChartDB();
const { fitView } = useReactFlow();
const [overlapGraph, setOverlapGraph] =
useState<Graph<string>>(createGraph());
// Check if there are any filtered items to determine initial showFilter state
const hasFilteredItems = useMemo(() => {
const hasHiddenTables = (hiddenTableIds ?? []).length > 0;
const hasSchemasFilter =
filteredSchemas &&
schemas.length > 0 &&
filteredSchemas.length < schemas.length;
return hasHiddenTables || hasSchemasFilter;
}, [filteredSchemas, hiddenTableIds, schemas]);
const [showFilter, setShowFilter] = useState(false);
const hasInitialized = useRef(false);
// Only auto-show filter on initial load if there are filtered items
// Wait for data to be defined (not just empty arrays) before initializing
useEffect(() => {
const dataLoaded =
filteredSchemas !== undefined && hiddenTableIds !== undefined;
if (!hasInitialized.current && dataLoaded) {
// Add 2 seconds delay to ensure all data is fully loaded
const timer = setTimeout(() => {
if (hasFilteredItems) {
setShowFilter(true);
}
hasInitialized.current = true;
}, 2000);
return () => clearTimeout(timer);
}
}, [hasFilteredItems, filteredSchemas, hiddenTableIds]);
const reorderTables = useCallback(
(
options: { updateHistory?: boolean } = {
@@ -77,6 +122,8 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
fitView,
setOverlapGraph,
overlapGraph,
setShowFilter,
showFilter,
}}
>
{children}

View File

@@ -78,6 +78,9 @@ export interface ChartDBContext {
events: EventEmitter<ChartDBEvent>;
readonly?: boolean;
highlightedCustomType?: DBCustomType;
highlightCustomTypeId: (id?: string) => void;
filteredSchemas?: string[];
filterSchemas: (schemaIds: string[]) => void;
@@ -92,6 +95,10 @@ export interface ChartDBContext {
updateDiagramUpdatedAt: () => Promise<void>;
clearDiagramData: () => Promise<void>;
deleteDiagram: () => Promise<void>;
updateDiagramData: (
diagram: Diagram,
options?: { forceUpdateStorage?: boolean }
) => Promise<void>;
// Database type operations
updateDatabaseType: (databaseType: DatabaseType) => Promise<void>;
@@ -277,6 +284,11 @@ export interface ChartDBContext {
customType: Partial<DBCustomType>,
options?: { updateHistory: boolean }
) => Promise<void>;
// Filters
hiddenTableIds?: string[];
addHiddenTableId: (tableId: string) => Promise<void>;
removeHiddenTableId: (tableId: string) => Promise<void>;
}
export const chartDBContext = createContext<ChartDBContext>({
@@ -289,6 +301,7 @@ export const chartDBContext = createContext<ChartDBContext>({
areas: [],
customTypes: [],
schemas: [],
highlightCustomTypeId: emptyFn,
filteredSchemas: [],
filterSchemas: emptyFn,
currentDiagram: {
@@ -308,6 +321,7 @@ export const chartDBContext = createContext<ChartDBContext>({
loadDiagramFromData: emptyFn,
clearDiagramData: emptyFn,
deleteDiagram: emptyFn,
updateDiagramData: emptyFn,
// Database type operations
updateDatabaseType: emptyFn,
@@ -372,4 +386,9 @@ export const chartDBContext = createContext<ChartDBContext>({
removeCustomType: emptyFn,
removeCustomTypes: emptyFn,
updateCustomType: emptyFn,
// Filters
hiddenTableIds: [],
addHiddenTableId: emptyFn,
removeHiddenTableId: emptyFn,
});

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { DBTable } from '@/lib/domain/db-table';
import { deepCopy, generateId } from '@/lib/utils';
import { randomColor } from '@/lib/colors';
@@ -29,6 +29,7 @@ import {
DBCustomTypeKind,
type DBCustomType,
} from '@/lib/domain/db-custom-type';
import { useConfig } from '@/hooks/use-config';
export interface ChartDBProviderProps {
diagram?: Diagram;
@@ -39,11 +40,17 @@ export const ChartDBProvider: React.FC<
React.PropsWithChildren<ChartDBProviderProps>
> = ({ children, diagram, readonly: readonlyProp }) => {
const { hasDiff } = useDiff();
let db = useStorage();
const dbStorage = useStorage();
let db = dbStorage;
const events = useEventEmitter<ChartDBEvent>();
const { setSchemasFilter, schemasFilter } = useLocalConfig();
const { addUndoAction, resetRedoStack, resetUndoStack } =
useRedoUndoStack();
const {
getHiddenTablesForDiagram,
hideTableForDiagram,
unhideTableForDiagram,
} = useConfig();
const [diagramId, setDiagramId] = useState('');
const [diagramName, setDiagramName] = useState('');
const [diagramCreatedAt, setDiagramCreatedAt] = useState<Date>(new Date());
@@ -65,8 +72,12 @@ export const ChartDBProvider: React.FC<
const [customTypes, setCustomTypes] = useState<DBCustomType[]>(
diagram?.customTypes ?? []
);
const [hiddenTableIds, setHiddenTableIds] = useState<string[]>([]);
const { events: diffEvents } = useDiff();
const [highlightedCustomTypeId, setHighlightedCustomTypeId] =
useState<string>();
const diffCalculatedHandler = useCallback((event: DiffCalculatedEvent) => {
const { tablesAdded, fieldsAdded, relationshipsAdded } = event.data;
setTables((tables) =>
@@ -85,6 +96,14 @@ export const ChartDBProvider: React.FC<
diffEvents.useSubscription(diffCalculatedHandler);
// Sync hiddenTableIds with config
useEffect(() => {
if (diagramId) {
const hiddenTables = getHiddenTablesForDiagram(diagramId);
setHiddenTableIds(hiddenTables);
}
}, [diagramId, getHiddenTablesForDiagram]);
const defaultSchemaName = defaultSchemas[databaseType];
const readonly = useMemo(
@@ -137,17 +156,18 @@ export const ChartDBProvider: React.FC<
return undefined;
}
const schemasFilterFromCache =
(schemasFilter[diagramId] ?? []).length === 0
? undefined // in case of empty filter, skip cache
: schemasFilter[diagramId];
const schemasFilterFromCache = schemasFilter[diagramId];
return (
schemasFilterFromCache ?? [
schemas.find((s) => s.name === defaultSchemaName)?.id ??
schemas[0]?.id,
]
);
// If there's an explicit filter set (even if empty), use it
if (schemasFilterFromCache !== undefined) {
return schemasFilterFromCache;
}
// Only default to showing schemas if no filter has been set
return [
schemas.find((s) => s.name === defaultSchemaName)?.id ??
schemas[0]?.id,
];
}, [schemasFilter, diagramId, schemas, defaultSchemaName]);
const currentDiagram: Diagram = useMemo(
@@ -304,22 +324,27 @@ export const ChartDBProvider: React.FC<
);
const addTables: ChartDBContext['addTables'] = useCallback(
async (tables: DBTable[], options = { updateHistory: true }) => {
setTables((currentTables) => [...currentTables, ...tables]);
async (tablesToAdd: DBTable[], options = { updateHistory: true }) => {
setTables((currentTables) => [...currentTables, ...tablesToAdd]);
const updatedAt = new Date();
setDiagramUpdatedAt(updatedAt);
await Promise.all([
db.updateDiagram({ id: diagramId, attributes: { updatedAt } }),
...tables.map((table) => db.addTable({ diagramId, table })),
...tablesToAdd.map((table) =>
db.addTable({ diagramId, table })
),
]);
events.emit({ action: 'add_tables', data: { tables } });
events.emit({
action: 'add_tables',
data: { tables: tablesToAdd },
});
if (options.updateHistory) {
addUndoAction({
action: 'addTables',
redoData: { tables },
undoData: { tableIds: tables.map((t) => t.id) },
redoData: { tables: tablesToAdd },
undoData: { tableIds: tablesToAdd.map((t) => t.id) },
});
resetRedoStack();
}
@@ -778,13 +803,23 @@ export const ChartDBProvider: React.FC<
options = { updateHistory: true }
) => {
const fields = getTable(tableId)?.fields ?? [];
setTables((tables) =>
tables.map((table) =>
table.id === tableId
? { ...table, fields: [...table.fields, field] }
: table
)
);
setTables((tables) => {
return tables.map((table) => {
if (table.id === tableId) {
db.updateTable({
id: tableId,
attributes: {
...table,
fields: [...table.fields, field],
},
});
return { ...table, fields: [...table.fields, field] };
}
return table;
});
});
events.emit({
action: 'add_field',
@@ -805,13 +840,6 @@ export const ChartDBProvider: React.FC<
setDiagramUpdatedAt(updatedAt);
await Promise.all([
db.updateDiagram({ id: diagramId, attributes: { updatedAt } }),
db.updateTable({
id: tableId,
attributes: {
...table,
fields: [...table.fields, field],
},
}),
]);
if (options.updateHistory) {
@@ -1508,22 +1536,37 @@ export const ChartDBProvider: React.FC<
[db, diagramId, setAreas, getArea, addUndoAction, resetRedoStack]
);
const highlightCustomTypeId = useCallback(
(id?: string) => setHighlightedCustomTypeId(id),
[setHighlightedCustomTypeId]
);
const highlightedCustomType = useMemo(() => {
return highlightedCustomTypeId
? customTypes.find((type) => type.id === highlightedCustomTypeId)
: undefined;
}, [highlightedCustomTypeId, customTypes]);
const loadDiagramFromData: ChartDBContext['loadDiagramFromData'] =
useCallback(
async (diagram) => {
(diagram) => {
setDiagramId(diagram.id);
setDiagramName(diagram.name);
setDatabaseType(diagram.databaseType);
setDatabaseEdition(diagram.databaseEdition);
setTables(diagram?.tables ?? []);
setRelationships(diagram?.relationships ?? []);
setDependencies(diagram?.dependencies ?? []);
setAreas(diagram?.areas ?? []);
setCustomTypes(diagram?.customTypes ?? []);
setTables(diagram.tables ?? []);
setRelationships(diagram.relationships ?? []);
setDependencies(diagram.dependencies ?? []);
setAreas(diagram.areas ?? []);
setCustomTypes(diagram.customTypes ?? []);
setDiagramCreatedAt(diagram.createdAt);
setDiagramUpdatedAt(diagram.updatedAt);
setHighlightedCustomTypeId(undefined);
events.emit({ action: 'load_diagram', data: { diagram } });
resetRedoStack();
resetUndoStack();
},
[
setDiagramId,
@@ -1537,10 +1580,23 @@ export const ChartDBProvider: React.FC<
setCustomTypes,
setDiagramCreatedAt,
setDiagramUpdatedAt,
setHighlightedCustomTypeId,
events,
resetRedoStack,
resetUndoStack,
]
);
const updateDiagramData: ChartDBContext['updateDiagramData'] = useCallback(
async (diagram, options) => {
const st = options?.forceUpdateStorage ? dbStorage : db;
await st.deleteDiagram(diagram.id);
await st.addDiagram({ diagram });
loadDiagramFromData(diagram);
},
[db, dbStorage, loadDiagramFromData]
);
const loadDiagram: ChartDBContext['loadDiagram'] = useCallback(
async (diagramId: string) => {
const diagram = await db.getDiagram(diagramId, {
@@ -1704,6 +1760,29 @@ export const ChartDBProvider: React.FC<
]
);
const addHiddenTableId: ChartDBContext['addHiddenTableId'] = useCallback(
async (tableId: string) => {
if (!hiddenTableIds.includes(tableId)) {
setHiddenTableIds((prev) => [...prev, tableId]);
await hideTableForDiagram(diagramId, tableId);
}
},
[hiddenTableIds, diagramId, hideTableForDiagram]
);
const removeHiddenTableId: ChartDBContext['removeHiddenTableId'] =
useCallback(
async (tableId: string) => {
if (hiddenTableIds.includes(tableId)) {
setHiddenTableIds((prev) =>
prev.filter((id) => id !== tableId)
);
await unhideTableForDiagram(diagramId, tableId);
}
},
[hiddenTableIds, diagramId, unhideTableForDiagram]
);
return (
<chartDBContext.Provider
value={{
@@ -1720,6 +1799,7 @@ export const ChartDBProvider: React.FC<
events,
readonly,
filterSchemas,
updateDiagramData,
updateDiagramId,
updateDiagramName,
loadDiagram,
@@ -1776,6 +1856,11 @@ export const ChartDBProvider: React.FC<
removeCustomType,
removeCustomTypes,
updateCustomType,
hiddenTableIds,
addHiddenTableId,
removeHiddenTableId,
highlightCustomTypeId,
highlightedCustomType,
}}
>
{children}

View File

@@ -8,9 +8,23 @@ export interface ConfigContext {
config?: Partial<ChartDBConfig>;
updateFn?: (config: ChartDBConfig) => ChartDBConfig;
}) => Promise<void>;
getHiddenTablesForDiagram: (diagramId: string) => string[];
setHiddenTablesForDiagram: (
diagramId: string,
hiddenTableIds: string[]
) => Promise<void>;
hideTableForDiagram: (diagramId: string, tableId: string) => Promise<void>;
unhideTableForDiagram: (
diagramId: string,
tableId: string
) => Promise<void>;
}
export const ConfigContext = createContext<ConfigContext>({
config: undefined,
updateConfig: emptyFn,
getHiddenTablesForDiagram: () => [],
setHiddenTablesForDiagram: emptyFn,
hideTableForDiagram: emptyFn,
unhideTableForDiagram: emptyFn,
});

View File

@@ -44,8 +44,86 @@ export const ConfigProvider: React.FC<React.PropsWithChildren> = ({
return promise;
};
const getHiddenTablesForDiagram = (diagramId: string): string[] => {
return config?.hiddenTablesByDiagram?.[diagramId] ?? [];
};
const setHiddenTablesForDiagram = async (
diagramId: string,
hiddenTableIds: string[]
): Promise<void> => {
return updateConfig({
updateFn: (currentConfig) => ({
...currentConfig,
hiddenTablesByDiagram: {
...currentConfig.hiddenTablesByDiagram,
[diagramId]: hiddenTableIds,
},
}),
});
};
const hideTableForDiagram = async (
diagramId: string,
tableId: string
): Promise<void> => {
return updateConfig({
updateFn: (currentConfig) => {
const currentHiddenTables =
currentConfig.hiddenTablesByDiagram?.[diagramId] ?? [];
if (currentHiddenTables.includes(tableId)) {
return currentConfig; // Already hidden, no change needed
}
return {
...currentConfig,
hiddenTablesByDiagram: {
...currentConfig.hiddenTablesByDiagram,
[diagramId]: [...currentHiddenTables, tableId],
},
};
},
});
};
const unhideTableForDiagram = async (
diagramId: string,
tableId: string
): Promise<void> => {
return updateConfig({
updateFn: (currentConfig) => {
const currentHiddenTables =
currentConfig.hiddenTablesByDiagram?.[diagramId] ?? [];
const filteredTables = currentHiddenTables.filter(
(id) => id !== tableId
);
if (filteredTables.length === currentHiddenTables.length) {
return currentConfig; // Not hidden, no change needed
}
return {
...currentConfig,
hiddenTablesByDiagram: {
...currentConfig.hiddenTablesByDiagram,
[diagramId]: filteredTables,
},
};
},
});
};
return (
<ConfigContext.Provider value={{ config, updateConfig }}>
<ConfigContext.Provider
value={{
config,
updateConfig,
getHiddenTablesForDiagram,
setHiddenTablesForDiagram,
hideTableForDiagram,
unhideTableForDiagram,
}}
>
{children}
</ConfigContext.Provider>
);

View File

@@ -32,14 +32,20 @@ export interface DiffContext {
originalDiagram: Diagram | null;
diffMap: DiffMap;
hasDiff: boolean;
isSummaryOnly: boolean;
calculateDiff: ({
diagram,
newDiagram,
options,
}: {
diagram: Diagram;
newDiagram: Diagram;
options?: {
summaryOnly?: boolean;
};
}) => void;
resetDiff: () => void;
// table diff
checkIfTableHasChange: ({ tableId }: { tableId: string }) => boolean;
@@ -60,6 +66,15 @@ export interface DiffContext {
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;
getFieldNewCharacterMaximumLength: ({
fieldId,
}: {
fieldId: string;
}) => string | null;
getFieldNewScale: ({ fieldId }: { fieldId: string }) => number | null;
getFieldNewPrecision: ({ fieldId }: { fieldId: string }) => number | null;
// relationship diff
checkIfNewRelationship: ({

View File

@@ -6,7 +6,10 @@ import type {
} from './diff-context';
import { diffContext } from './diff-context';
import { generateDiff, getDiffMapKey } from './diff-check/diff-check';
import {
generateDiff,
getDiffMapKey,
} from '@/lib/domain/diff/diff-check/diff-check';
import type { Diagram } from '@/lib/domain/diagram';
import { useEventEmitter } from 'ahooks';
import type { DBField } from '@/lib/domain/db-field';
@@ -29,6 +32,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
const [fieldsChanged, setFieldsChanged] = React.useState<
Map<string, boolean>
>(new Map<string, boolean>());
const [isSummaryOnly, setIsSummaryOnly] = React.useState<boolean>(false);
const events = useEventEmitter<DiffEvent>();
@@ -124,7 +128,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
);
const calculateDiff: DiffContext['calculateDiff'] = useCallback(
({ diagram, newDiagram: newDiagramArg }) => {
({ diagram, newDiagram: newDiagramArg, options }) => {
const {
diffMap: newDiffs,
changedTables: newChangedTables,
@@ -136,6 +140,7 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
setFieldsChanged(newChangedFields);
setNewDiagram(newDiagramArg);
setOriginalDiagram(diagram);
setIsSummaryOnly(options?.summaryOnly ?? false);
events.emit({
action: 'diff_calculated',
@@ -302,6 +307,117 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
[diffMap]
);
const getFieldNewPrimaryKey = useCallback<
DiffContext['getFieldNewPrimaryKey']
>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'primaryKey',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as boolean;
}
}
return null;
},
[diffMap]
);
const getFieldNewNullable = useCallback<DiffContext['getFieldNewNullable']>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'nullable',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as boolean;
}
}
return null;
},
[diffMap]
);
const getFieldNewCharacterMaximumLength = useCallback<
DiffContext['getFieldNewCharacterMaximumLength']
>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'characterMaximumLength',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as string;
}
}
return null;
},
[diffMap]
);
const getFieldNewScale = useCallback<DiffContext['getFieldNewScale']>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'scale',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as number;
}
}
return null;
},
[diffMap]
);
const getFieldNewPrecision = useCallback<
DiffContext['getFieldNewPrecision']
>(
({ fieldId }) => {
const fieldKey = getDiffMapKey({
diffObject: 'field',
objectId: fieldId,
attribute: 'precision',
});
if (diffMap.has(fieldKey)) {
const diff = diffMap.get(fieldKey);
if (diff?.type === 'changed') {
return diff.newValue as number;
}
}
return null;
},
[diffMap]
);
const checkIfNewRelationship = useCallback<
DiffContext['checkIfNewRelationship']
>(
@@ -336,6 +452,15 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
[diffMap]
);
const resetDiff = useCallback<DiffContext['resetDiff']>(() => {
setDiffMap(new Map<string, ChartDBDiff>());
setTablesChanged(new Map<string, boolean>());
setFieldsChanged(new Map<string, boolean>());
setNewDiagram(null);
setOriginalDiagram(null);
setIsSummaryOnly(false);
}, []);
return (
<diffContext.Provider
value={{
@@ -343,8 +468,10 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
originalDiagram,
diffMap,
hasDiff: diffMap.size > 0,
isSummaryOnly,
calculateDiff,
resetDiff,
// table diff
getTableNewName,
@@ -359,6 +486,11 @@ export const DiffProvider: React.FC<React.PropsWithChildren> = ({
checkIfNewField,
getFieldNewName,
getFieldNewType,
getFieldNewPrimaryKey,
getFieldNewNullable,
getFieldNewCharacterMaximumLength,
getFieldNewScale,
getFieldNewPrecision,
// relationship diff
checkIfNewRelationship,

View File

@@ -8,6 +8,7 @@ export enum KeyboardShortcutAction {
TOGGLE_SIDE_PANEL = 'toggle_side_panel',
SHOW_ALL = 'show_all',
TOGGLE_THEME = 'toggle_theme',
TOGGLE_FILTER = 'toggle_filter',
}
export interface KeyboardShortcut {
@@ -71,6 +72,13 @@ export const keyboardShortcuts: Record<
keyCombinationMac: 'meta+m',
keyCombinationWin: 'ctrl+m',
},
[KeyboardShortcutAction.TOGGLE_FILTER]: {
action: KeyboardShortcutAction.TOGGLE_FILTER,
keyCombinationLabelMac: '⌘F',
keyCombinationLabelWin: 'Ctrl+F',
keyCombinationMac: 'meta+f',
keyCombinationWin: 'ctrl+f',
},
};
export interface KeyboardShortcutForOS {

View File

@@ -36,10 +36,6 @@ export interface LayoutContext {
hideSidePanel: () => void;
showSidePanel: () => void;
toggleSidePanel: () => void;
isSelectSchemaOpen: boolean;
openSelectSchema: () => void;
closeSelectSchema: () => void;
}
export const layoutContext = createContext<LayoutContext>({
@@ -70,8 +66,4 @@ export const layoutContext = createContext<LayoutContext>({
hideSidePanel: emptyFn,
showSidePanel: emptyFn,
toggleSidePanel: emptyFn,
isSelectSchemaOpen: false,
openSelectSchema: emptyFn,
closeSelectSchema: emptyFn,
});

View File

@@ -23,8 +23,6 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
React.useState<SidebarSection>('tables');
const [isSidePanelShowed, setIsSidePanelShowed] =
React.useState<boolean>(isDesktop);
const [isSelectSchemaOpen, setIsSelectSchemaOpen] =
React.useState<boolean>(false);
const closeAllTablesInSidebar: LayoutContext['closeAllTablesInSidebar'] =
() => setOpenedTableInSidebar('');
@@ -88,11 +86,6 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
setOpenedTableInSidebar(customTypeId);
};
const openSelectSchema: LayoutContext['openSelectSchema'] = () =>
setIsSelectSchemaOpen(true);
const closeSelectSchema: LayoutContext['closeSelectSchema'] = () =>
setIsSelectSchemaOpen(false);
return (
<layoutContext.Provider
value={{
@@ -108,9 +101,6 @@ export const LayoutProvider: React.FC<React.PropsWithChildren> = ({
hideSidePanel,
showSidePanel,
toggleSidePanel,
isSelectSchemaOpen,
openSelectSchema,
closeSelectSchema,
openedDependencyInSidebar,
openDependencyFromSidebar,
closeAllDependenciesInSidebar,

View File

@@ -19,10 +19,8 @@ export interface LocalConfigContext {
showCardinality: boolean;
setShowCardinality: (showCardinality: boolean) => void;
hideMultiSchemaNotification: boolean;
setHideMultiSchemaNotification: (
hideMultiSchemaNotification: boolean
) => void;
showFieldAttributes: boolean;
setShowFieldAttributes: (showFieldAttributes: boolean) => void;
githubRepoOpened: boolean;
setGithubRepoOpened: (githubRepoOpened: boolean) => void;
@@ -50,8 +48,8 @@ export const LocalConfigContext = createContext<LocalConfigContext>({
showCardinality: true,
setShowCardinality: emptyFn,
hideMultiSchemaNotification: false,
setHideMultiSchemaNotification: emptyFn,
showFieldAttributes: true,
setShowFieldAttributes: emptyFn,
githubRepoOpened: false,
setGithubRepoOpened: emptyFn,

View File

@@ -7,7 +7,7 @@ const themeKey = 'theme';
const scrollActionKey = 'scroll_action';
const schemasFilterKey = 'schemas_filter';
const showCardinalityKey = 'show_cardinality';
const hideMultiSchemaNotificationKey = 'hide_multi_schema_notification';
const showFieldAttributesKey = 'show_field_attributes';
const githubRepoOpenedKey = 'github_repo_opened';
const starUsDialogLastOpenKey = 'star_us_dialog_last_open';
const showDependenciesOnCanvasKey = 'show_dependencies_on_canvas';
@@ -34,10 +34,9 @@ export const LocalConfigProvider: React.FC<React.PropsWithChildren> = ({
(localStorage.getItem(showCardinalityKey) || 'true') === 'true'
);
const [hideMultiSchemaNotification, setHideMultiSchemaNotification] =
const [showFieldAttributes, setShowFieldAttributes] =
React.useState<boolean>(
(localStorage.getItem(hideMultiSchemaNotificationKey) ||
'false') === 'true'
(localStorage.getItem(showFieldAttributesKey) || 'true') === 'true'
);
const [githubRepoOpened, setGithubRepoOpened] = React.useState<boolean>(
@@ -71,13 +70,6 @@ export const LocalConfigProvider: React.FC<React.PropsWithChildren> = ({
localStorage.setItem(githubRepoOpenedKey, githubRepoOpened.toString());
}, [githubRepoOpened]);
useEffect(() => {
localStorage.setItem(
hideMultiSchemaNotificationKey,
hideMultiSchemaNotification.toString()
);
}, [hideMultiSchemaNotification]);
useEffect(() => {
localStorage.setItem(themeKey, theme);
}, [theme]);
@@ -119,8 +111,8 @@ export const LocalConfigProvider: React.FC<React.PropsWithChildren> = ({
setSchemasFilter,
showCardinality,
setShowCardinality,
hideMultiSchemaNotification,
setHideMultiSchemaNotification,
showFieldAttributes,
setShowFieldAttributes,
setGithubRepoOpened,
githubRepoOpened,
starUsDialogLastOpen,

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
import { createContext } from 'react';
import { emptyFn } from '@/lib/utils';
export type Theme = 'light' | 'dark' | 'system';
export type EffectiveTheme = Exclude<Theme, 'system'>;
import type { Theme, EffectiveTheme } from '@/lib/types';
export type { Theme, EffectiveTheme };
export interface ThemeContext {
theme: Theme;

View File

@@ -48,6 +48,7 @@ export const ThemeProvider: React.FC<React.PropsWithChildren> = ({
handleThemeToggle,
{
preventDefault: true,
enableOnFormTags: true,
},
[handleThemeToggle]
);

View File

@@ -35,7 +35,22 @@ import type { OnChange } from '@monaco-editor/react';
import { useDebounce } from '@/hooks/use-debounce-v2';
import { InstructionsSection } from './instructions-section/instructions-section';
import { parseSQLError } from '@/lib/data/sql-import';
import type { editor } from 'monaco-editor';
import type { editor, IDisposable } from 'monaco-editor';
import { waitFor } from '@/lib/utils';
import {
validateSQL,
type ValidationResult,
} from '@/lib/data/sql-import/sql-validator';
import { SQLValidationStatus } from './sql-validation-status';
const calculateContentSizeMB = (content: string): number => {
return content.length / (1024 * 1024); // Convert to MB
};
const calculateIsLargeFile = (content: string): boolean => {
const contentSizeMB = calculateContentSizeMB(content);
return contentSizeMB > 2; // Consider large if over 2MB
};
const errorScriptOutputMessage =
'Invalid JSON. Please correct it or contact us at support@chartdb.io for help.';
@@ -117,6 +132,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
const { effectiveTheme } = useTheme();
const [errorMessage, setErrorMessage] = useState('');
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const pasteDisposableRef = useRef<IDisposable | null>(null);
const { t } = useTranslation();
const { isSm: isDesktop } = useBreakpoint('sm');
@@ -124,6 +140,11 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
const [showCheckJsonButton, setShowCheckJsonButton] = useState(false);
const [isCheckingJson, setIsCheckingJson] = useState(false);
const [showSSMSInfoDialog, setShowSSMSInfoDialog] = useState(false);
const [sqlValidation, setSqlValidation] = useState<ValidationResult | null>(
null
);
const [isAutoFixing, setIsAutoFixing] = useState(false);
const [showAutoFixButton, setShowAutoFixButton] = useState(false);
useEffect(() => {
setScriptResult('');
@@ -134,11 +155,33 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
// Check if the ddl is valid
useEffect(() => {
if (importMethod !== 'ddl') {
setSqlValidation(null);
setShowAutoFixButton(false);
return;
}
if (!scriptResult.trim()) return;
if (!scriptResult.trim()) {
setSqlValidation(null);
setShowAutoFixButton(false);
return;
}
// First run our validation based on database type
const validation = validateSQL(scriptResult, databaseType);
setSqlValidation(validation);
// If we have auto-fixable errors, show the auto-fix button
if (validation.fixedSQL && validation.errors.length > 0) {
setShowAutoFixButton(true);
// Don't try to parse invalid SQL
setErrorMessage('SQL contains syntax errors');
return;
}
// Hide auto-fix button if no fixes available
setShowAutoFixButton(false);
// Validate the SQL (either original or already fixed)
parseSQLError({
sqlContent: scriptResult,
sourceDatabaseType: databaseType,
@@ -184,8 +227,44 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
}
}, [errorMessage.length, onImport, scriptResult]);
const handleAutoFix = useCallback(() => {
if (sqlValidation?.fixedSQL) {
setIsAutoFixing(true);
setShowAutoFixButton(false);
setErrorMessage('');
// Apply the fix with a delay so user sees the fixing message
setTimeout(() => {
setScriptResult(sqlValidation.fixedSQL!);
setTimeout(() => {
setIsAutoFixing(false);
}, 100);
}, 1000);
}
}, [sqlValidation, setScriptResult]);
const handleErrorClick = useCallback((line: number) => {
if (editorRef.current) {
// Set cursor to the error line
editorRef.current.setPosition({ lineNumber: line, column: 1 });
editorRef.current.revealLineInCenter(line);
editorRef.current.focus();
}
}, []);
const formatEditor = useCallback(() => {
if (editorRef.current) {
const model = editorRef.current.getModel();
if (model) {
const content = model.getValue();
// Skip formatting for large files (> 2MB)
if (calculateIsLargeFile(content)) {
return;
}
}
setTimeout(() => {
editorRef.current
?.getAction('editor.action.formatDocument')
@@ -211,7 +290,8 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
const handleCheckJson = useCallback(async () => {
setIsCheckingJson(true);
const fixedJson = await fixMetadataJson(scriptResult);
await waitFor(1000);
const fixedJson = fixMetadataJson(scriptResult);
if (isStringMetadataJson(fixedJson)) {
setScriptResult(fixedJson);
@@ -227,37 +307,69 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
setIsCheckingJson(false);
}, [scriptResult, setScriptResult, formatEditor]);
const detectAndSetImportMethod = useCallback(() => {
const content = editorRef.current?.getValue();
if (content && content.trim()) {
const detectedType = detectContentType(content);
if (detectedType && detectedType !== importMethod) {
setImportMethod(detectedType);
}
}
}, [setImportMethod, importMethod]);
const [editorDidMount, setEditorDidMount] = useState(false);
useEffect(() => {
if (editorRef.current && editorDidMount) {
editorRef.current.onDidPaste(() => {
setTimeout(() => {
editorRef.current
?.getAction('editor.action.formatDocument')
?.run();
}, 0);
setTimeout(detectAndSetImportMethod, 0);
});
}
}, [detectAndSetImportMethod, editorDidMount]);
// Cleanup paste handler on unmount
return () => {
if (pasteDisposableRef.current) {
pasteDisposableRef.current.dispose();
pasteDisposableRef.current = null;
}
};
}, []);
const handleEditorDidMount = useCallback(
(editor: editor.IStandaloneCodeEditor) => {
editorRef.current = editor;
setEditorDidMount(true);
// Cleanup previous disposable if it exists
if (pasteDisposableRef.current) {
pasteDisposableRef.current.dispose();
pasteDisposableRef.current = null;
}
// Add paste handler for all modes
const disposable = editor.onDidPaste(() => {
const model = editor.getModel();
if (!model) return;
const content = model.getValue();
// Skip formatting for large files (> 2MB) to prevent browser freezing
const isLargeFile = calculateIsLargeFile(content);
// First, detect content type to determine if we should switch modes
const detectedType = detectContentType(content);
if (detectedType && detectedType !== importMethod) {
// Switch to the detected mode immediately
setImportMethod(detectedType);
// Only format if it's JSON (query mode) AND file is not too large
if (detectedType === 'query' && !isLargeFile) {
// For JSON mode, format after a short delay
setTimeout(() => {
editor
.getAction('editor.action.formatDocument')
?.run();
}, 100);
}
// For DDL mode, do NOT format as it can break the SQL
} else {
// Content type didn't change, apply formatting based on current mode
if (importMethod === 'query' && !isLargeFile) {
// Only format JSON content if not too large
setTimeout(() => {
editor
.getAction('editor.action.formatDocument')
?.run();
}, 100);
}
// For DDL mode or large files, do NOT format
}
});
pasteDisposableRef.current = disposable;
},
[]
[importMethod, setImportMethod]
);
const renderHeader = useCallback(() => {
@@ -314,7 +426,7 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
: 'dbml-light'
}
options={{
formatOnPaste: true,
formatOnPaste: false, // Never format on paste - we handle it manually
minimap: { enabled: false },
scrollBeyondLastLine: false,
automaticLayout: true,
@@ -343,10 +455,13 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
</Suspense>
</div>
{errorMessage ? (
<div className="mt-2 flex shrink-0 items-center gap-2">
<p className="text-xs text-red-700">{errorMessage}</p>
</div>
{errorMessage || (importMethod === 'ddl' && sqlValidation) ? (
<SQLValidationStatus
validation={sqlValidation}
errorMessage={errorMessage}
isAutoFixing={isAutoFixing}
onErrorClick={handleErrorClick}
/>
) : null}
</div>
),
@@ -357,6 +472,9 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
effectiveTheme,
debouncedHandleInputChange,
handleEditorDidMount,
sqlValidation,
isAutoFixing,
handleErrorClick,
]
);
@@ -442,13 +560,28 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
)
)}
</Button>
) : showAutoFixButton && importMethod === 'ddl' ? (
<Button
type="button"
variant="secondary"
onClick={handleAutoFix}
disabled={isAutoFixing}
className="bg-sky-600 text-white hover:bg-sky-700"
>
{isAutoFixing ? (
<Spinner size="small" />
) : (
'Try auto-fix'
)}
</Button>
) : keepDialogAfterImport ? (
<Button
type="button"
variant="default"
disabled={
scriptResult.trim().length === 0 ||
errorMessage.length > 0
errorMessage.length > 0 ||
isAutoFixing
}
onClick={handleImport}
>
@@ -461,7 +594,8 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
variant="default"
disabled={
scriptResult.trim().length === 0 ||
errorMessage.length > 0
errorMessage.length > 0 ||
isAutoFixing
}
onClick={handleImport}
>
@@ -494,6 +628,10 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
handleCheckJson,
goBack,
t,
importMethod,
isAutoFixing,
showAutoFixButton,
handleAutoFix,
]);
return (

View File

@@ -0,0 +1,179 @@
import React, { useMemo } from 'react';
import { CheckCircle, AlertTriangle, MessageCircleWarning } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/alert/alert';
import type { ValidationResult } from '@/lib/data/sql-import/sql-validator';
import { Separator } from '@/components/separator/separator';
import { ScrollArea } from '@/components/scroll-area/scroll-area';
import { Spinner } from '@/components/spinner/spinner';
interface SQLValidationStatusProps {
validation?: ValidationResult | null;
errorMessage: string;
isAutoFixing?: boolean;
onErrorClick?: (line: number) => void;
}
export const SQLValidationStatus: React.FC<SQLValidationStatusProps> = ({
validation,
errorMessage,
isAutoFixing = false,
onErrorClick,
}) => {
const hasErrors = useMemo(
() => validation?.errors.length && validation.errors.length > 0,
[validation?.errors]
);
const hasWarnings = useMemo(
() => validation?.warnings && validation.warnings.length > 0,
[validation?.warnings]
);
const wasAutoFixed = useMemo(
() =>
validation?.warnings?.some((w) =>
w.message.includes('Auto-fixed')
) || false,
[validation?.warnings]
);
if (!validation && !errorMessage && !isAutoFixing) return null;
if (isAutoFixing) {
return (
<>
<Separator className="mb-1 mt-2" />
<div className="rounded-md border border-sky-200 bg-sky-50 dark:border-sky-800 dark:bg-sky-950">
<div className="space-y-3 p-3 pt-2 text-sky-700 dark:text-sky-300">
<div className="flex items-start gap-2">
<Spinner className="mt-0.5 size-4 shrink-0 text-sky-700 dark:text-sky-300" />
<div className="flex-1 text-sm text-sky-700 dark:text-sky-300">
Auto-fixing SQL syntax errors...
</div>
</div>
</div>
</div>
</>
);
}
// If we have parser errors (errorMessage) after validation
if (errorMessage && !hasErrors) {
return (
<>
<Separator className="mb-1 mt-2" />
<div className="mb-1 flex shrink-0 items-center gap-2">
<p className="text-xs text-red-700">{errorMessage}</p>
</div>
</>
);
}
return (
<>
<Separator className="mb-1 mt-2" />
{hasErrors ? (
<div className="rounded-md border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950">
<ScrollArea className="h-24">
<div className="space-y-3 p-3 pt-2 text-red-700 dark:text-red-300">
{validation?.errors
.slice(0, 3)
.map((error, idx) => (
<div
key={idx}
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">
<button
onClick={() =>
onErrorClick?.(error.line)
}
className="rounded font-medium underline hover:text-red-600 focus:outline-none focus:ring-1 focus:ring-red-500 dark:hover:text-red-200"
type="button"
>
Line {error.line}
</button>
<span className="mx-1">:</span>
<span className="text-xs">
{error.message}
</span>
{error.suggestion && (
<div className="mt-1 flex items-start gap-2">
<span className="text-xs font-medium ">
{error.suggestion}
</span>
</div>
)}
</div>
</div>
))}
{validation?.errors &&
validation?.errors.length > 3 ? (
<div className="flex items-center gap-2">
<MessageCircleWarning className="mt-0.5 size-4 shrink-0 text-red-700 dark:text-red-300" />
<span className="text-xs font-medium">
{validation.errors.length - 3} more
error
{validation.errors.length - 3 > 1
? 's'
: ''}
</span>
</div>
) : null}
</div>
</ScrollArea>
</div>
) : null}
{wasAutoFixed && !hasErrors ? (
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950">
<CheckCircle className="size-4 text-green-600 dark:text-green-400" />
<AlertDescription className="text-sm text-green-700 dark:text-green-300">
SQL syntax errors were automatically fixed. Your SQL is
now ready to import.
</AlertDescription>
</Alert>
) : null}
{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">
<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" />
<div className="flex-1 text-sm text-sky-700 dark:text-sky-300">
<div className="mb-1 font-medium">
Import Info:
</div>
{validation?.warnings.map(
(warning, idx) => (
<div
key={idx}
className="ml-2 text-xs"
>
{warning.message}
</div>
)
)}
</div>
</div>
</div>
</ScrollArea>
</div>
) : null}
{!hasErrors && !hasWarnings && !errorMessage && validation ? (
<div className="rounded-md border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950">
<div className="space-y-3 p-3 pt-2 text-green-700 dark:text-green-300">
<div className="flex items-start gap-2">
<CheckCircle className="mt-0.5 size-4 shrink-0 text-green-700 dark:text-green-300" />
<div className="flex-1 text-sm text-green-700 dark:text-green-300">
SQL syntax validated successfully
</div>
</div>
</div>
</div>
) : null}
</>
);
};

View File

@@ -0,0 +1,2 @@
export const MAX_TABLES_IN_DIAGRAM = 500;
export const MAX_TABLES_WITHOUT_SHOWING_FILTER = 50;

View File

@@ -0,0 +1,683 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { Button } from '@/components/button/button';
import { Input } from '@/components/input/input';
import { Search, AlertCircle, Check, X, View, Table } from 'lucide-react';
import { Checkbox } from '@/components/checkbox/checkbox';
import type { DatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata';
import { schemaNameToDomainSchemaName } from '@/lib/domain/db-schema';
import { cn } from '@/lib/utils';
import {
DialogDescription,
DialogFooter,
DialogHeader,
DialogInternalContent,
DialogTitle,
} from '@/components/dialog/dialog';
import type { SelectedTable } from '@/lib/data/import-metadata/filter-metadata';
import { generateTableKey } from '@/lib/domain';
import { Spinner } from '@/components/spinner/spinner';
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationPrevious,
PaginationNext,
} from '@/components/pagination/pagination';
import { MAX_TABLES_IN_DIAGRAM } from './constants';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useTranslation } from 'react-i18next';
export interface SelectTablesProps {
databaseMetadata?: DatabaseMetadata;
onImport: ({
selectedTables,
databaseMetadata,
}: {
selectedTables?: SelectedTable[];
databaseMetadata?: DatabaseMetadata;
}) => Promise<void>;
onBack: () => void;
isLoading?: boolean;
}
const TABLES_PER_PAGE = 10;
interface TableInfo {
key: string;
schema?: string;
tableName: string;
fullName: string;
type: 'table' | 'view';
}
export const SelectTables: React.FC<SelectTablesProps> = ({
databaseMetadata,
onImport,
onBack,
isLoading = false,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [showTables, setShowTables] = useState(true);
const [showViews, setShowViews] = useState(false);
const { t } = useTranslation();
const [isImporting, setIsImporting] = useState(false);
// Prepare all tables and views with their metadata
const allTables = useMemo(() => {
const tables: TableInfo[] = [];
// Add regular tables
databaseMetadata?.tables.forEach((table) => {
const schema = schemaNameToDomainSchemaName(table.schema);
const tableName = table.table;
const key = `table:${generateTableKey({ tableName, schemaName: schema })}`;
tables.push({
key,
schema,
tableName,
fullName: schema ? `${schema}.${tableName}` : tableName,
type: 'table',
});
});
// Add views
databaseMetadata?.views?.forEach((view) => {
const schema = schemaNameToDomainSchemaName(view.schema);
const viewName = view.view_name;
if (!viewName) {
return;
}
const key = `view:${generateTableKey({
tableName: viewName,
schemaName: schema,
})}`;
tables.push({
key,
schema,
tableName: viewName,
fullName:
schema === 'default' ? viewName : `${schema}.${viewName}`,
type: 'view',
});
});
return tables.sort((a, b) => a.fullName.localeCompare(b.fullName));
}, [databaseMetadata?.tables, databaseMetadata?.views]);
// Count tables and views separately
const tableCount = useMemo(
() => allTables.filter((t) => t.type === 'table').length,
[allTables]
);
const viewCount = useMemo(
() => allTables.filter((t) => t.type === 'view').length,
[allTables]
);
// Initialize selectedTables with all tables (not views) if less than 100 tables
const [selectedTables, setSelectedTables] = useState<Set<string>>(() => {
const tables = allTables.filter((t) => t.type === 'table');
if (tables.length < MAX_TABLES_IN_DIAGRAM) {
return new Set(tables.map((t) => t.key));
}
return new Set();
});
// Filter tables based on search term and type filters
const filteredTables = useMemo(() => {
let filtered = allTables;
// Filter by type
filtered = filtered.filter((table) => {
if (table.type === 'table' && !showTables) return false;
if (table.type === 'view' && !showViews) return false;
return true;
});
// Filter by search term
if (searchTerm.trim()) {
const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter(
(table) =>
table.tableName.toLowerCase().includes(searchLower) ||
table.schema?.toLowerCase().includes(searchLower) ||
table.fullName.toLowerCase().includes(searchLower)
);
}
return filtered;
}, [allTables, searchTerm, showTables, showViews]);
// Calculate pagination
const totalPages = useMemo(
() => Math.max(1, Math.ceil(filteredTables.length / TABLES_PER_PAGE)),
[filteredTables.length]
);
const paginatedTables = useMemo(() => {
const startIndex = (currentPage - 1) * TABLES_PER_PAGE;
const endIndex = startIndex + TABLES_PER_PAGE;
return filteredTables.slice(startIndex, endIndex);
}, [filteredTables, currentPage]);
// Get currently visible selected tables
const visibleSelectedTables = useMemo(() => {
return paginatedTables.filter((table) => selectedTables.has(table.key));
}, [paginatedTables, selectedTables]);
const canAddMore = useMemo(
() => selectedTables.size < MAX_TABLES_IN_DIAGRAM,
[selectedTables.size]
);
const hasSearchResults = useMemo(
() => filteredTables.length > 0,
[filteredTables.length]
);
const allVisibleSelected = useMemo(
() =>
visibleSelectedTables.length === paginatedTables.length &&
paginatedTables.length > 0,
[visibleSelectedTables.length, paginatedTables.length]
);
const canSelectAllFiltered = useMemo(
() =>
filteredTables.length > 0 &&
filteredTables.some((table) => !selectedTables.has(table.key)) &&
canAddMore,
[filteredTables, selectedTables, canAddMore]
);
// Reset to first page when search changes
useEffect(() => {
setCurrentPage(1);
}, [searchTerm]);
const handleTableToggle = useCallback(
(tableKey: string) => {
const newSelected = new Set(selectedTables);
if (newSelected.has(tableKey)) {
newSelected.delete(tableKey);
} else if (selectedTables.size < MAX_TABLES_IN_DIAGRAM) {
newSelected.add(tableKey);
}
setSelectedTables(newSelected);
},
[selectedTables]
);
const handleTogglePageSelection = useCallback(() => {
const newSelected = new Set(selectedTables);
if (allVisibleSelected) {
// Deselect all on current page
for (const table of paginatedTables) {
newSelected.delete(table.key);
}
} else {
// Select all on current page
for (const table of paginatedTables) {
if (newSelected.size >= MAX_TABLES_IN_DIAGRAM) break;
newSelected.add(table.key);
}
}
setSelectedTables(newSelected);
}, [allVisibleSelected, paginatedTables, selectedTables]);
const handleSelectAllFiltered = useCallback(() => {
const newSelected = new Set(selectedTables);
for (const table of filteredTables) {
if (newSelected.size >= MAX_TABLES_IN_DIAGRAM) break;
newSelected.add(table.key);
}
setSelectedTables(newSelected);
}, [filteredTables, selectedTables]);
const handleNextPage = useCallback(() => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
}, [currentPage, totalPages]);
const handlePrevPage = useCallback(() => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
}, [currentPage]);
const handleClearSelection = useCallback(() => {
setSelectedTables(new Set());
}, []);
const handleConfirm = useCallback(async () => {
if (isImporting) {
return;
}
setIsImporting(true);
try {
const selectedTableObjects: SelectedTable[] = Array.from(
selectedTables
)
.map((key): SelectedTable | null => {
const table = allTables.find((t) => t.key === key);
if (!table) return null;
return {
schema: table.schema,
table: table.tableName,
type: table.type,
} satisfies SelectedTable;
})
.filter((t): t is SelectedTable => t !== null);
await onImport({
selectedTables: selectedTableObjects,
databaseMetadata,
});
} finally {
setIsImporting(false);
}
}, [selectedTables, allTables, onImport, databaseMetadata, isImporting]);
const { isMd: isDesktop } = useBreakpoint('md');
const renderPagination = useCallback(
() => (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={handlePrevPage}
className={cn(
'cursor-pointer',
currentPage === 1 &&
'pointer-events-none opacity-50'
)}
/>
</PaginationItem>
<PaginationItem>
<span className="px-3 text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={handleNextPage}
className={cn(
'cursor-pointer',
(currentPage >= totalPages ||
filteredTables.length === 0) &&
'pointer-events-none opacity-50'
)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
),
[
currentPage,
totalPages,
handlePrevPage,
handleNextPage,
filteredTables.length,
]
);
if (isLoading) {
return (
<div className="flex h-[400px] items-center justify-center">
<div className="text-center">
<Spinner className="mb-4" />
<p className="text-sm text-muted-foreground">
Parsing database metadata...
</p>
</div>
</div>
);
}
return (
<>
<DialogHeader>
<DialogTitle>Select Tables to Import</DialogTitle>
<DialogDescription>
{tableCount} {tableCount === 1 ? 'table' : 'tables'}
{viewCount > 0 && (
<>
{' and '}
{viewCount} {viewCount === 1 ? 'view' : 'views'}
</>
)}
{' found. '}
{allTables.length > MAX_TABLES_IN_DIAGRAM
? `Select up to ${MAX_TABLES_IN_DIAGRAM} to import.`
: 'Choose which ones to import.'}
</DialogDescription>
</DialogHeader>
<DialogInternalContent>
<div className="flex h-full flex-col space-y-4">
{/* Warning/Info Banner */}
{allTables.length > MAX_TABLES_IN_DIAGRAM ? (
<div
className={cn(
'flex items-center gap-2 rounded-lg p-3 text-sm',
'bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-200'
)}
>
<AlertCircle className="size-4 shrink-0" />
<span>
Due to performance limitations, you can import a
maximum of {MAX_TABLES_IN_DIAGRAM} tables.
</span>
</div>
) : null}
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search tables..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-9"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
{/* Selection Status and Actions - Responsive layout */}
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
{/* Left side: selection count -> checkboxes -> results found */}
<div className="flex flex-col items-center gap-3 text-sm sm:flex-row sm:items-center sm:gap-4">
<div className="flex flex-col items-center gap-1 sm:flex-row sm:items-center sm:gap-4">
<span className="text-center font-medium">
{selectedTables.size} /{' '}
{Math.min(
MAX_TABLES_IN_DIAGRAM,
allTables.length
)}{' '}
items selected
</span>
</div>
<div className="flex items-center gap-3 sm:border-x sm:px-4">
<div className="flex items-center gap-2">
<Checkbox
checked={showTables}
onCheckedChange={(checked) => {
// Prevent unchecking if it's the only one checked
if (!checked && !showViews) return;
setShowTables(!!checked);
}}
/>
<Table
className="size-4"
strokeWidth={1.5}
/>
<span>tables</span>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={showViews}
onCheckedChange={(checked) => {
// Prevent unchecking if it's the only one checked
if (!checked && !showTables) return;
setShowViews(!!checked);
}}
/>
<View
className="size-4"
strokeWidth={1.5}
/>
<span>views</span>
</div>
</div>
<span className="hidden text-muted-foreground sm:inline">
{filteredTables.length}{' '}
{filteredTables.length === 1
? 'result'
: 'results'}{' '}
found
</span>
</div>
{/* Right side: action buttons */}
<div className="flex flex-wrap items-center justify-center gap-2">
{hasSearchResults && (
<>
{/* Show page selection button when not searching and no selection */}
{!searchTerm &&
selectedTables.size === 0 && (
<Button
variant="outline"
size="sm"
onClick={
handleTogglePageSelection
}
disabled={
paginatedTables.length === 0
}
>
{allVisibleSelected
? 'Deselect'
: 'Select'}{' '}
page
</Button>
)}
{/* Show Select all button when there are unselected tables */}
{canSelectAllFiltered &&
selectedTables.size === 0 && (
<Button
variant="outline"
size="sm"
onClick={
handleSelectAllFiltered
}
disabled={!canSelectAllFiltered}
title={(() => {
const unselectedCount =
filteredTables.filter(
(table) =>
!selectedTables.has(
table.key
)
).length;
const remainingCapacity =
MAX_TABLES_IN_DIAGRAM -
selectedTables.size;
if (
unselectedCount >
remainingCapacity
) {
return `Can only select ${remainingCapacity} more tables (${MAX_TABLES_IN_DIAGRAM} max limit)`;
}
return undefined;
})()}
>
{(() => {
const unselectedCount =
filteredTables.filter(
(table) =>
!selectedTables.has(
table.key
)
).length;
const remainingCapacity =
MAX_TABLES_IN_DIAGRAM -
selectedTables.size;
if (
unselectedCount >
remainingCapacity
) {
return `Select ${remainingCapacity} of ${unselectedCount}`;
}
return `Select all ${unselectedCount}`;
})()}
</Button>
)}
</>
)}
{selectedTables.size > 0 && (
<>
{/* Show page selection/deselection button when user has selections */}
{paginatedTables.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleTogglePageSelection}
>
{allVisibleSelected
? 'Deselect'
: 'Select'}{' '}
page
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleClearSelection}
>
Clear selection
</Button>
</>
)}
</div>
</div>
</div>
{/* Table List */}
<div className="flex min-h-[428px] flex-1 flex-col">
{hasSearchResults ? (
<>
<div className="flex-1 py-4">
<div className="space-y-1">
{paginatedTables.map((table) => {
const isSelected = selectedTables.has(
table.key
);
const isDisabled =
!isSelected &&
selectedTables.size >=
MAX_TABLES_IN_DIAGRAM;
return (
<div
key={table.key}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
{
'cursor-not-allowed':
isDisabled,
'bg-muted hover:bg-muted/80':
isSelected,
'hover:bg-accent':
!isSelected &&
!isDisabled,
}
)}
>
<Checkbox
checked={isSelected}
disabled={isDisabled}
onCheckedChange={() =>
handleTableToggle(
table.key
)
}
/>
{table.type === 'view' ? (
<View
className="size-4"
strokeWidth={1.5}
/>
) : (
<Table
className="size-4"
strokeWidth={1.5}
/>
)}
<span className="flex-1">
{table.schema ? (
<span className="text-muted-foreground">
{table.schema}.
</span>
) : null}
<span className="font-medium">
{table.tableName}
</span>
{table.type === 'view' && (
<span className="ml-2 text-xs text-muted-foreground">
(view)
</span>
)}
</span>
{isSelected && (
<Check className="size-4 text-pink-600" />
)}
</div>
);
})}
</div>
</div>
</>
) : (
<div className="flex h-full items-center justify-center py-4">
<p className="text-sm text-muted-foreground">
{searchTerm
? 'No tables found matching your search.'
: 'Start typing to search for tables...'}
</p>
</div>
)}
</div>
{isDesktop ? renderPagination() : null}
</DialogInternalContent>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:space-x-2 md:justify-between md:gap-0">
<Button
type="button"
variant="secondary"
onClick={onBack}
disabled={isImporting}
>
{t('new_diagram_dialog.back')}
</Button>
<Button
onClick={handleConfirm}
disabled={selectedTables.size === 0 || isImporting}
className="bg-pink-500 text-white hover:bg-pink-600"
>
{isImporting ? (
<>
<Spinner className="mr-2 size-4 text-white" />
Importing...
</>
) : (
`Import ${selectedTables.size} Tables`
)}
</Button>
{!isDesktop ? renderPagination() : null}
</DialogFooter>
</>
);
};

View File

@@ -1,4 +1,5 @@
export enum CreateDiagramDialogStep {
SELECT_DATABASE = 'SELECT_DATABASE',
IMPORT_DATABASE = 'IMPORT_DATABASE',
SELECT_TABLES = 'SELECT_TABLES',
}

View File

@@ -15,9 +15,13 @@ import type { DatabaseEdition } from '@/lib/domain/database-edition';
import { SelectDatabase } from './select-database/select-database';
import { CreateDiagramDialogStep } from './create-diagram-dialog-step';
import { ImportDatabase } from '../common/import-database/import-database';
import { SelectTables } from '../common/select-tables/select-tables';
import { useTranslation } from 'react-i18next';
import type { BaseDialogProps } from '../common/base-dialog-props';
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';
export interface CreateDiagramDialogProps extends BaseDialogProps {}
@@ -42,6 +46,8 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
const { listDiagrams, addDiagram } = useStorage();
const [diagramNumber, setDiagramNumber] = useState<number>(1);
const navigate = useNavigate();
const [parsedMetadata, setParsedMetadata] = useState<DatabaseMetadata>();
const [isParsingMetadata, setIsParsingMetadata] = useState(false);
useEffect(() => {
setDatabaseEdition(undefined);
@@ -62,49 +68,72 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
setDatabaseEdition(undefined);
setScriptResult('');
setImportMethod('query');
setParsedMetadata(undefined);
}, [dialog.open]);
const hasExistingDiagram = (diagramId ?? '').trim().length !== 0;
const importNewDiagram = useCallback(async () => {
let diagram: Diagram | undefined;
const importNewDiagram = useCallback(
async ({
selectedTables,
databaseMetadata,
}: {
selectedTables?: SelectedTable[];
databaseMetadata?: DatabaseMetadata;
} = {}) => {
let diagram: Diagram | undefined;
if (importMethod === 'ddl') {
diagram = await sqlImportToDiagram({
sqlContent: scriptResult,
sourceDatabaseType: databaseType,
targetDatabaseType: databaseType,
if (importMethod === 'ddl') {
diagram = await sqlImportToDiagram({
sqlContent: scriptResult,
sourceDatabaseType: databaseType,
targetDatabaseType: databaseType,
});
} else {
let metadata: DatabaseMetadata | undefined = databaseMetadata;
if (!metadata) {
metadata = loadDatabaseMetadata(scriptResult);
}
if (selectedTables && selectedTables.length > 0) {
metadata = filterMetadataByTables({
metadata,
selectedTables,
});
}
diagram = await loadFromDatabaseMetadata({
databaseType,
databaseMetadata: metadata,
diagramNumber,
databaseEdition:
databaseEdition?.trim().length === 0
? undefined
: databaseEdition,
});
}
await addDiagram({ diagram });
await updateConfig({
config: { defaultDiagramId: diagram.id },
});
} else {
const databaseMetadata: DatabaseMetadata =
loadDatabaseMetadata(scriptResult);
diagram = await loadFromDatabaseMetadata({
databaseType,
databaseMetadata,
diagramNumber,
databaseEdition:
databaseEdition?.trim().length === 0
? undefined
: databaseEdition,
});
}
await addDiagram({ diagram });
await updateConfig({ config: { defaultDiagramId: diagram.id } });
closeCreateDiagramDialog();
navigate(`/diagrams/${diagram.id}`);
}, [
importMethod,
databaseType,
addDiagram,
databaseEdition,
closeCreateDiagramDialog,
navigate,
updateConfig,
scriptResult,
diagramNumber,
]);
closeCreateDiagramDialog();
navigate(`/diagrams/${diagram.id}`);
},
[
importMethod,
databaseType,
addDiagram,
databaseEdition,
closeCreateDiagramDialog,
navigate,
updateConfig,
scriptResult,
diagramNumber,
]
);
const createEmptyDiagram = useCallback(async () => {
const diagram: Diagram = {
@@ -138,10 +167,56 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
openImportDBMLDialog,
]);
const importNewDiagramOrFilterTables = useCallback(async () => {
try {
setIsParsingMetadata(true);
if (importMethod === 'ddl') {
await importNewDiagram();
} else {
// Parse metadata asynchronously to avoid blocking the UI
const metadata = await new Promise<DatabaseMetadata>(
(resolve, reject) => {
setTimeout(() => {
try {
const result =
loadDatabaseMetadata(scriptResult);
resolve(result);
} catch (err) {
reject(err);
}
}, 0);
}
);
const totalTablesAndViews =
metadata.tables.length + (metadata.views?.length || 0);
setParsedMetadata(metadata);
// Check if it's a large database that needs table selection
if (totalTablesAndViews > MAX_TABLES_WITHOUT_SHOWING_FILTER) {
setStep(CreateDiagramDialogStep.SELECT_TABLES);
} else {
await importNewDiagram({
databaseMetadata: metadata,
});
}
}
} finally {
setIsParsingMetadata(false);
}
}, [importMethod, scriptResult, importNewDiagram]);
return (
<Dialog
{...dialog}
onOpenChange={(open) => {
// Don't allow closing while parsing metadata
if (isParsingMetadata) {
return;
}
if (!hasExistingDiagram) {
return;
}
@@ -154,6 +229,8 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
<DialogContent
className="flex max-h-dvh w-full flex-col md:max-w-[900px]"
showClose={hasExistingDiagram}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
{step === CreateDiagramDialogStep.SELECT_DATABASE ? (
<SelectDatabase
@@ -165,9 +242,9 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
setStep(CreateDiagramDialogStep.IMPORT_DATABASE)
}
/>
) : (
) : step === CreateDiagramDialogStep.IMPORT_DATABASE ? (
<ImportDatabase
onImport={importNewDiagram}
onImport={importNewDiagramOrFilterTables}
onCreateEmptyDiagram={createEmptyDiagram}
databaseEdition={databaseEdition}
databaseType={databaseType}
@@ -180,8 +257,18 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
title={t('new_diagram_dialog.import_database.title')}
importMethod={importMethod}
setImportMethod={setImportMethod}
keepDialogAfterImport={true}
/>
)}
) : step === CreateDiagramDialogStep.SELECT_TABLES ? (
<SelectTables
isLoading={isParsingMetadata || !parsedMetadata}
databaseMetadata={parsedMetadata}
onImport={importNewDiagram}
onBack={() =>
setStep(CreateDiagramDialogStep.IMPORT_DATABASE)
}
/>
) : null}
</DialogContent>
</Dialog>
);

View File

@@ -5,7 +5,7 @@ import React, {
Suspense,
useRef,
} from 'react';
import * as monaco from 'monaco-editor';
import type * as monaco from 'monaco-editor';
import { useDialog } from '@/hooks/use-dialog';
import {
Dialog,
@@ -23,53 +23,24 @@ 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 } from '@/lib/dbml-import';
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';
interface DBMLError {
message: string;
line: number;
column: number;
}
function parseDBMLError(error: unknown): DBMLError | null {
try {
if (typeof error === 'string') {
const parsed = JSON.parse(error);
if (parsed.diags?.[0]) {
const diag = parsed.diags[0];
return {
message: diag.message,
line: diag.location.start.line,
column: diag.location.start.column,
};
}
} else if (error && typeof error === 'object' && 'diags' in error) {
const parsed = error as {
diags: Array<{
message: string;
location: { start: { line: number; column: number } };
}>;
};
if (parsed.diags?.[0]) {
return {
message: parsed.diags[0].message,
line: parsed.diags[0].location.start.line,
column: parsed.diags[0].location.start.column,
};
}
}
} catch (e) {
console.error('Error parsing DBML error:', e);
}
return null;
}
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;
@@ -145,39 +116,8 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
}
}, [reorder, reorderTables]);
const highlightErrorLine = useCallback((error: DBMLError) => {
if (!editorRef.current) return;
const model = editorRef.current.getModel();
if (!model) return;
const decorations = [
{
range: new monaco.Range(
error.line,
1,
error.line,
model.getLineMaxColumn(error.line)
),
options: {
isWholeLine: true,
className: 'dbml-error-line',
glyphMarginClassName: 'dbml-error-glyph',
hoverMessage: { value: error.message },
overviewRuler: {
color: '#ff0000',
position: monaco.editor.OverviewRulerLane.Right,
darkColor: '#ff0000',
},
},
},
];
decorationsCollection.current?.set(decorations);
}, []);
const clearDecorations = useCallback(() => {
decorationsCollection.current?.clear();
clearErrorHighlight(decorationsCollection.current);
}, []);
const validateDBML = useCallback(
@@ -189,7 +129,8 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
if (!content.trim()) return;
try {
const sanitizedContent = sanitizeDBML(content);
const preprocessedContent = preprocessDBML(content);
const sanitizedContent = sanitizeDBML(preprocessedContent);
const parser = new Parser();
parser.parse(sanitizedContent, 'dbml');
} catch (e) {
@@ -199,7 +140,12 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
t('import_dbml_dialog.error.description') +
` (1 error found - in line ${parsedError.line})`
);
highlightErrorLine(parsedError);
highlightErrorLine({
error: parsedError,
model: editorRef.current?.getModel(),
editorDecorationsCollection:
decorationsCollection.current,
});
} else {
setErrorMessage(
e instanceof Error ? e.message : JSON.stringify(e)
@@ -207,7 +153,7 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
}
}
},
[clearDecorations, highlightErrorLine, t]
[clearDecorations, t]
);
const debouncedValidateRef = useRef<((value: string) => void) | null>(null);
@@ -242,13 +188,11 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
if (!dbmlContent.trim() || errorMessage) return;
try {
// Sanitize DBML content before importing
const sanitizedContent = sanitizeDBML(dbmlContent);
const importedDiagram = await importDBMLToDiagram(sanitizedContent);
const importedDiagram = await importDBMLToDiagram(dbmlContent);
const tableIdsToRemove = tables
.filter((table) =>
importedDiagram.tables?.some(
(t) =>
(t: DBTable) =>
t.name === table.name && t.schema === table.schema
)
)
@@ -257,19 +201,21 @@ Ref: comments.user_id > users.id // Each comment is written by one user`;
const relationshipIdsToRemove = relationships
.filter((relationship) => {
const sourceTable = tables.find(
(table) => table.id === relationship.sourceTableId
(table: DBTable) =>
table.id === relationship.sourceTableId
);
const targetTable = tables.find(
(table) => table.id === relationship.targetTableId
(table: DBTable) =>
table.id === relationship.targetTableId
);
if (!sourceTable || !targetTable) return true;
const replacementSourceTable = importedDiagram.tables?.find(
(table) =>
(table: DBTable) =>
table.name === sourceTable.name &&
table.schema === sourceTable.schema
);
const replacementTargetTable = importedDiagram.tables?.find(
(table) =>
(table: DBTable) =>
table.name === targetTable.name &&
table.schema === targetTable.schema
);

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDialog } from '@/hooks/use-dialog';
import {
Dialog,
@@ -17,11 +17,23 @@ import type { DBSchema } from '@/lib/domain/db-schema';
import { schemaNameToSchemaId } from '@/lib/domain/db-schema';
import type { BaseDialogProps } from '../common/base-dialog-props';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/input/input';
import { Separator } from '@/components/separator/separator';
import { Group, SquarePlus } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip/tooltip';
import { useChartDB } from '@/hooks/use-chartdb';
import { defaultSchemas } from '@/lib/data/default-schemas';
import { Label } from '@/components/label/label';
export interface TableSchemaDialogProps extends BaseDialogProps {
table?: DBTable;
schemas: DBSchema[];
onConfirm: (schema: string) => void;
onConfirm: ({ schema }: { schema: DBSchema }) => void;
allowSchemaCreation?: boolean;
}
export const TableSchemaDialog: React.FC<TableSchemaDialogProps> = ({
@@ -29,27 +41,90 @@ export const TableSchemaDialog: React.FC<TableSchemaDialogProps> = ({
table,
schemas,
onConfirm,
allowSchemaCreation = false,
}) => {
const { t } = useTranslation();
const [selectedSchema, setSelectedSchema] = React.useState<string>(
const { databaseType, filteredSchemas, filterSchemas } = useChartDB();
const [selectedSchemaId, setSelectedSchemaId] = useState<string>(
table?.schema
? schemaNameToSchemaId(table.schema)
: (schemas?.[0]?.id ?? '')
);
const allowSchemaSelection = useMemo(
() => schemas && schemas.length > 0,
[schemas]
);
const defaultSchemaName = useMemo(
() => defaultSchemas?.[databaseType],
[databaseType]
);
const [isCreatingNew, setIsCreatingNew] =
useState<boolean>(!allowSchemaSelection);
const [newSchemaName, setNewSchemaName] = useState<string>(
allowSchemaCreation && !allowSchemaSelection
? (defaultSchemaName ?? '')
: ''
);
useEffect(() => {
if (!dialog.open) return;
setSelectedSchema(
setSelectedSchemaId(
table?.schema
? schemaNameToSchemaId(table.schema)
: (schemas?.[0]?.id ?? '')
);
}, [dialog.open, schemas, table?.schema]);
setIsCreatingNew(!allowSchemaSelection);
setNewSchemaName(
allowSchemaCreation && !allowSchemaSelection
? (defaultSchemaName ?? '')
: ''
);
}, [
defaultSchemaName,
dialog.open,
schemas,
table?.schema,
allowSchemaSelection,
allowSchemaCreation,
]);
const { closeTableSchemaDialog } = useDialog();
const handleConfirm = useCallback(() => {
onConfirm(selectedSchema);
}, [onConfirm, selectedSchema]);
let createdSchemaId: string;
if (isCreatingNew && newSchemaName.trim()) {
const newSchema: DBSchema = {
id: schemaNameToSchemaId(newSchemaName.trim()),
name: newSchemaName.trim(),
tableCount: 0,
};
createdSchemaId = newSchema.id;
onConfirm({ schema: newSchema });
} else {
const schema = schemas.find((s) => s.id === selectedSchemaId);
if (!schema) return;
createdSchemaId = schema.id;
onConfirm({ schema });
}
filterSchemas([
...(filteredSchemas ?? schemas.map((s) => s.id)),
createdSchemaId,
]);
}, [
onConfirm,
selectedSchemaId,
schemas,
isCreatingNew,
newSchemaName,
filteredSchemas,
filterSchemas,
]);
const schemaOptions: SelectBoxOption[] = useMemo(
() =>
@@ -60,6 +135,25 @@ export const TableSchemaDialog: React.FC<TableSchemaDialogProps> = ({
[schemas]
);
const renderSwitchCreateOrSelectButton = useCallback(
() => (
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setIsCreatingNew(!isCreatingNew)}
disabled={!allowSchemaSelection || !allowSchemaCreation}
>
{!isCreatingNew ? (
<SquarePlus className="mr-2 size-4 " />
) : (
<Group className="mr-2 size-4 " />
)}
{isCreatingNew ? 'Select existing schema' : 'Create new schema'}
</Button>
),
[isCreatingNew, allowSchemaSelection, allowSchemaCreation]
);
return (
<Dialog
{...dialog}
@@ -67,48 +161,106 @@ export const TableSchemaDialog: React.FC<TableSchemaDialogProps> = ({
if (!open) {
closeTableSchemaDialog();
}
setTimeout(() => (document.body.style.pointerEvents = ''), 500);
}}
>
<DialogContent className="flex flex-col" showClose>
<DialogHeader>
<DialogTitle>
{table
? t('update_table_schema_dialog.title')
: t('new_table_schema_dialog.title')}
{!allowSchemaSelection && allowSchemaCreation
? t('create_table_schema_dialog.title')
: table
? t('update_table_schema_dialog.title')
: t('new_table_schema_dialog.title')}
</DialogTitle>
<DialogDescription>
{table
? t('update_table_schema_dialog.description', {
tableName: table.name,
})
: t('new_table_schema_dialog.description')}
{!allowSchemaSelection && allowSchemaCreation
? t('create_table_schema_dialog.description')
: table
? t('update_table_schema_dialog.description', {
tableName: table.name,
})
: t('new_table_schema_dialog.description')}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-1">
<div className="grid w-full items-center gap-4">
<SelectBox
options={schemaOptions}
multiple={false}
value={selectedSchema}
onChange={(value) =>
setSelectedSchema(value as string)
}
/>
{!isCreatingNew ? (
<SelectBox
options={schemaOptions}
multiple={false}
value={selectedSchemaId}
onChange={(value) =>
setSelectedSchemaId(value as string)
}
/>
) : (
<div className="flex flex-col gap-2">
{allowSchemaCreation &&
!allowSchemaSelection ? (
<Label htmlFor="new-schema-name">
Schema Name
</Label>
) : null}
<Input
id="new-schema-name"
value={newSchemaName}
onChange={(e) =>
setNewSchemaName(e.target.value)
}
placeholder={`Enter schema name.${defaultSchemaName ? ` e.g. ${defaultSchemaName}.` : ''}`}
autoFocus
/>
</div>
)}
{allowSchemaCreation && allowSchemaSelection ? (
<>
<div className="relative">
<Separator className="my-2" />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-background px-2 text-xs text-muted-foreground">
or
</span>
</div>
{allowSchemaSelection ? (
renderSwitchCreateOrSelectButton()
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
{renderSwitchCreateOrSelectButton()}
</span>
</TooltipTrigger>
<TooltipContent>
<p>No existing schemas available</p>
</TooltipContent>
</Tooltip>
)}
</>
) : null}
</div>
</div>
<DialogFooter className="flex gap-1 md:justify-between">
<DialogClose asChild>
<Button variant="secondary">
{table
? t('update_table_schema_dialog.cancel')
: t('new_table_schema_dialog.cancel')}
{isCreatingNew
? t('create_table_schema_dialog.cancel')
: table
? t('update_table_schema_dialog.cancel')
: t('new_table_schema_dialog.cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={handleConfirm}>
{table
? t('update_table_schema_dialog.confirm')
: t('new_table_schema_dialog.confirm')}
<Button
onClick={handleConfirm}
disabled={isCreatingNew && !newSchemaName.trim()}
>
{isCreatingNew
? t('create_table_schema_dialog.create')
: table
? t('update_table_schema_dialog.confirm')
: t('new_table_schema_dialog.confirm')}
</Button>
</DialogClose>
</DialogFooter>

View File

@@ -83,6 +83,7 @@
}
body {
@apply bg-background text-foreground;
overscroll-behavior-x: none;
}
.text-editable {
@@ -154,3 +155,29 @@
background-size: 650%;
}
}
/* Edit button emphasis animation */
@keyframes dbml_edit-button-emphasis {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
background-color: rgba(59, 130, 246, 0);
}
50% {
transform: scale(1.1);
box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
background-color: rgba(59, 130, 246, 0.1);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
background-color: rgba(59, 130, 246, 0);
}
}
.dbml-edit-button-emphasis {
animation: dbml_edit-button-emphasis 0.6s ease-in-out;
animation-iteration-count: 1;
position: relative;
z-index: 10;
}

View File

@@ -23,23 +23,25 @@ import { bn, bnMetadata } from './locales/bn';
import { gu, guMetadata } from './locales/gu';
import { vi, viMetadata } from './locales/vi';
import { ar, arMetadata } from './locales/ar';
import { hr, hrMetadata } from './locales/hr';
export const languages: LanguageMetadata[] = [
enMetadata,
esMetadata,
frMetadata,
deMetadata,
esMetadata,
ukMetadata,
ruMetadata,
trMetadata,
hrMetadata,
pt_BRMetadata,
hiMetadata,
jaMetadata,
ko_KRMetadata,
pt_BRMetadata,
ukMetadata,
ruMetadata,
zh_CNMetadata,
zh_TWMetadata,
neMetadata,
mrMetadata,
trMetadata,
id_IDMetadata,
teMetadata,
bnMetadata,
@@ -70,6 +72,7 @@ const resources = {
gu,
vi,
ar,
hr,
};
i18n.use(LanguageDetector)

View File

@@ -26,6 +26,8 @@ export const ar: LanguageTranslation = {
hide_sidebar: 'إخفاء الشريط الجانبي',
hide_cardinality: 'إخفاء الكاردينالية',
show_cardinality: 'إظهار الكاردينالية',
hide_field_attributes: 'إخفاء خصائص الحقل',
show_field_attributes: 'إظهار خصائص الحقل',
zoom_on_scroll: 'تكبير/تصغير عند التمرير',
theme: 'المظهر',
show_dependencies: 'إظهار الاعتمادات',
@@ -74,8 +76,8 @@ export const ar: LanguageTranslation = {
title: 'مخططات متعددة',
description:
'{{formattedSchemas}} :مخططات في هذا الرسم البياني. يتم حاليا عرض {{schemasCount}} هناك',
dont_show_again: 'لا تظهره مجدداً',
change_schema: 'تغيير',
// TODO: Translate
show_me: 'Show me',
none: 'لا شيء',
},
@@ -126,6 +128,8 @@ export const ar: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -151,6 +155,10 @@ export const ar: LanguageTranslation = {
delete_field: 'حذف الحقل',
// TODO: Translate
character_length: 'Max Length',
precision: 'الدقة',
scale: 'النطاق',
default_value: 'Default Value',
no_default: 'No default',
},
index_actions: {
title: 'خصائص الفهرس',
@@ -251,9 +259,12 @@ export const ar: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -269,6 +280,11 @@ export const ar: LanguageTranslation = {
redo: 'إعادة',
reorder_diagram: 'إعادة ترتيب الرسم البياني',
highlight_overlapping_tables: 'تمييز الجداول المتداخلة',
// TODO: Translate
filter: 'Filter Tables',
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
},
new_diagram_dialog: {
@@ -400,6 +416,13 @@ export const ar: LanguageTranslation = {
cancel: 'إلغاء',
confirm: 'تغيير',
},
create_table_schema_dialog: {
title: 'إنشاء مخطط جديد',
description:
'لا توجد مخططات حتى الآن. قم بإنشاء أول مخطط لتنظيم جداولك.',
create: 'إنشاء',
cancel: 'إلغاء',
},
star_us_dialog: {
title: '!ساعدنا على التحسن',

View File

@@ -26,6 +26,8 @@ export const bn: LanguageTranslation = {
hide_sidebar: 'সাইডবার লুকান',
hide_cardinality: 'কার্ডিনালিটি লুকান',
show_cardinality: 'কার্ডিনালিটি দেখান',
hide_field_attributes: 'ফিল্ড অ্যাট্রিবিউট লুকান',
show_field_attributes: 'ফিল্ড অ্যাট্রিবিউট দেখান',
zoom_on_scroll: 'স্ক্রলে জুম করুন',
theme: 'থিম',
show_dependencies: 'নির্ভরতাগুলি দেখান',
@@ -75,8 +77,8 @@ export const bn: LanguageTranslation = {
title: 'বহু স্কিমা',
description:
'{{schemasCount}} স্কিমা এই ডায়াগ্রামে রয়েছে। বর্তমানে প্রদর্শিত: {{formattedSchemas}}।',
dont_show_again: 'পুনরায় দেখাবেন না',
change_schema: 'পরিবর্তন করুন',
// TODO: Translate
show_me: 'Show me',
none: 'কিছুই না',
},
@@ -127,6 +129,8 @@ export const bn: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -151,7 +155,12 @@ export const bn: LanguageTranslation = {
no_comments: 'কোনো মন্তব্য নেই',
delete_field: 'ফিল্ড মুছুন',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'নির্ভুলতা',
scale: 'স্কেল',
},
index_actions: {
title: 'ইনডেক্স কর্ম',
@@ -251,9 +260,12 @@ export const bn: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -269,6 +281,12 @@ export const bn: LanguageTranslation = {
redo: 'পুনরায় করুন',
reorder_diagram: 'ডায়াগ্রাম পুনর্বিন্যাস করুন',
highlight_overlapping_tables: 'ওভারল্যাপিং টেবিল হাইলাইট করুন',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -400,6 +418,13 @@ export const bn: LanguageTranslation = {
cancel: 'বাতিল করুন',
confirm: 'পরিবর্তন করুন',
},
create_table_schema_dialog: {
title: 'নতুন স্কিমা তৈরি করুন',
description:
'এখনও কোনো স্কিমা নেই। আপনার টেবিলগুলি সংগঠিত করতে আপনার প্রথম স্কিমা তৈরি করুন।',
create: 'তৈরি করুন',
cancel: 'বাতিল করুন',
},
star_us_dialog: {
title: 'আমাদের উন্নত করতে সাহায্য করুন!',

View File

@@ -26,6 +26,8 @@ export const de: LanguageTranslation = {
hide_sidebar: 'Seitenleiste ausblenden',
hide_cardinality: 'Kardinalität ausblenden',
show_cardinality: 'Kardinalität anzeigen',
hide_field_attributes: 'Feldattribute ausblenden',
show_field_attributes: 'Feldattribute anzeigen',
zoom_on_scroll: 'Zoom beim Scrollen',
theme: 'Stil',
show_dependencies: 'Abhängigkeiten anzeigen',
@@ -75,8 +77,8 @@ export const de: LanguageTranslation = {
title: 'Mehrere Schemas',
description:
'{{schemasCount}} Schemas in diesem Diagramm. Derzeit angezeigt: {{formattedSchemas}}.',
dont_show_again: 'Nicht erneut anzeigen',
change_schema: 'Schema ändern',
// TODO: Translate
show_me: 'Show me',
none: 'Keine',
},
@@ -128,6 +130,8 @@ export const de: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -152,7 +156,12 @@ export const de: LanguageTranslation = {
no_comments: 'Keine Kommentare',
delete_field: 'Feld löschen',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'Präzision',
scale: 'Skalierung',
},
index_actions: {
title: 'Indexattribute',
@@ -253,9 +262,12 @@ export const de: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -270,7 +282,14 @@ export const de: LanguageTranslation = {
undo: 'Rückgängig',
redo: 'Wiederholen',
reorder_diagram: 'Diagramm neu anordnen',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'Überlappende Tabellen hervorheben',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -403,6 +422,13 @@ export const de: LanguageTranslation = {
cancel: 'Abbrechen',
confirm: 'Ändern',
},
create_table_schema_dialog: {
title: 'Neues Schema erstellen',
description:
'Es existieren noch keine Schemas. Erstellen Sie Ihr erstes Schema, um Ihre Tabellen zu organisieren.',
create: 'Erstellen',
cancel: 'Abbrechen',
},
star_us_dialog: {
title: 'Hilf uns, uns zu verbessern!',

View File

@@ -26,6 +26,8 @@ export const en = {
hide_sidebar: 'Hide Sidebar',
hide_cardinality: 'Hide Cardinality',
show_cardinality: 'Show Cardinality',
hide_field_attributes: 'Hide Field Attributes',
show_field_attributes: 'Show Field Attributes',
zoom_on_scroll: 'Zoom on Scroll',
theme: 'Theme',
show_dependencies: 'Show Dependencies',
@@ -73,8 +75,7 @@ export const en = {
title: 'Multiple Schemas',
description:
'{{schemasCount}} schemas in this diagram. Currently displaying: {{formattedSchemas}}.',
dont_show_again: "Don't show again",
change_schema: 'Change',
show_me: 'Show me',
none: 'none',
},
@@ -124,6 +125,8 @@ export const en = {
collapse: 'Collapse All',
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -144,8 +147,12 @@ export const en = {
title: 'Field Attributes',
unique: 'Unique',
character_length: 'Max Length',
precision: 'Precision',
scale: 'Scale',
comments: 'Comments',
no_comments: 'No comments',
default_value: 'Default Value',
no_default: 'No default',
delete_field: 'Delete Field',
},
index_actions: {
@@ -245,8 +252,11 @@ export const en = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
clear_field_highlight: 'Clear Highlight',
delete_custom_type: 'Delete',
},
delete_custom_type: 'Delete Type',
@@ -263,6 +273,10 @@ export const en = {
redo: 'Redo',
reorder_diagram: 'Reorder Diagram',
highlight_overlapping_tables: 'Highlight Overlapping Tables',
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -394,6 +408,14 @@ export const en = {
confirm: 'Change',
},
create_table_schema_dialog: {
title: 'Create New Schema',
description:
'No schemas exist yet. Create your first schema to organize your tables.',
create: 'Create',
cancel: 'Cancel',
},
star_us_dialog: {
title: 'Help us improve!',
description:

View File

@@ -24,6 +24,8 @@ export const es: LanguageTranslation = {
view: 'Ver',
hide_cardinality: 'Ocultar Cardinalidad',
show_cardinality: 'Mostrar Cardinalidad',
show_field_attributes: 'Mostrar Atributos de Campo',
hide_field_attributes: 'Ocultar Atributos de Campo',
show_sidebar: 'Mostrar Barra Lateral',
hide_sidebar: 'Ocultar Barra Lateral',
zoom_on_scroll: 'Zoom al Desplazarse',
@@ -117,6 +119,8 @@ export const es: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -141,7 +145,12 @@ export const es: LanguageTranslation = {
no_comments: 'Sin comentarios',
delete_field: 'Eliminar Campo',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'Precisión',
scale: 'Escala',
},
index_actions: {
title: 'Atributos del Índice',
@@ -241,9 +250,12 @@ export const es: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -258,7 +270,13 @@ export const es: LanguageTranslation = {
undo: 'Deshacer',
redo: 'Rehacer',
reorder_diagram: 'Reordenar Diagrama',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'Resaltar tablas superpuestas',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -392,6 +410,13 @@ export const es: LanguageTranslation = {
cancel: 'Cancelar',
confirm: 'Cambiar',
},
create_table_schema_dialog: {
title: 'Crear Nuevo Esquema',
description:
'Aún no existen esquemas. Crea tu primer esquema para organizar tus tablas.',
create: 'Crear',
cancel: 'Cancelar',
},
star_us_dialog: {
title: '¡Ayúdanos a mejorar!',
@@ -405,8 +430,8 @@ export const es: LanguageTranslation = {
title: 'Múltiples Esquemas',
description:
'{{schemasCount}} esquemas en este diagrama. Actualmente mostrando: {{formattedSchemas}}.',
dont_show_again: 'No mostrar de nuevo',
change_schema: 'Cambiar',
// TODO: Translate
show_me: 'Show me',
none: 'nada',
},
// TODO: Translate

View File

@@ -26,6 +26,8 @@ export const fr: LanguageTranslation = {
hide_sidebar: 'Cacher la Barre Latérale',
hide_cardinality: 'Cacher la Cardinalité',
show_cardinality: 'Afficher la Cardinalité',
hide_field_attributes: 'Masquer les Attributs de Champ',
show_field_attributes: 'Afficher les Attributs de Champ',
zoom_on_scroll: 'Zoom sur le Défilement',
theme: 'Thème',
show_dependencies: 'Afficher les Dépendances',
@@ -116,6 +118,8 @@ export const fr: LanguageTranslation = {
clear: 'Effacer le Filtre',
no_results:
'Aucune table trouvée correspondant à votre filtre.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
show_list: 'Afficher la Liste des Tableaux',
show_dbml: "Afficher l'éditeur DBML",
@@ -139,7 +143,12 @@ export const fr: LanguageTranslation = {
no_comments: 'Pas de commentaires',
delete_field: 'Supprimer le Champ',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'Précision',
scale: 'Échelle',
},
index_actions: {
title: "Attributs de l'Index",
@@ -239,9 +248,12 @@ export const fr: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -256,7 +268,13 @@ export const fr: LanguageTranslation = {
undo: 'Annuler',
redo: 'Rétablir',
reorder_diagram: 'Réorganiser le Diagramme',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'Surligner les tables chevauchées',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -345,8 +363,8 @@ export const fr: LanguageTranslation = {
title: 'Schémas Multiples',
description:
'{{schemasCount}} schémas dans ce diagramme. Actuellement affiché(s) : {{formattedSchemas}}.',
dont_show_again: 'Ne plus afficher',
change_schema: 'Changer',
// TODO: Translate
show_me: 'Show me',
none: 'Aucun',
},
@@ -372,6 +390,13 @@ export const fr: LanguageTranslation = {
cancel: 'Annuler',
confirm: 'Modifier',
},
create_table_schema_dialog: {
title: 'Créer un Nouveau Schéma',
description:
"Aucun schéma n'existe encore. Créez votre premier schéma pour organiser vos tables.",
create: 'Créer',
cancel: 'Annuler',
},
create_relationship_dialog: {
title: 'Créer une Relation',

View File

@@ -26,6 +26,8 @@ export const gu: LanguageTranslation = {
hide_sidebar: 'સાઇડબાર છુપાવો',
hide_cardinality: 'કાર્ડિનાલિટી છુપાવો',
show_cardinality: 'કાર્ડિનાલિટી બતાવો',
hide_field_attributes: 'ફીલ્ડ અટ્રિબ્યુટ્સ છુપાવો',
show_field_attributes: 'ફીલ્ડ અટ્રિબ્યુટ્સ બતાવો',
zoom_on_scroll: 'સ્ક્રોલ પર ઝૂમ કરો',
theme: 'થિમ',
show_dependencies: 'નિર્ભરતાઓ બતાવો',
@@ -75,8 +77,8 @@ export const gu: LanguageTranslation = {
title: 'કઈંક વધારે સ્કીમા',
description:
'{{schemasCount}} સ્કીમા આ ડાયાગ્રામમાં છે. હાલમાં દર્શાવેલ છે: {{formattedSchemas}}.',
dont_show_again: 'ફરીથી ન બતાવો',
change_schema: 'બદલો',
// TODO: Translate
show_me: 'Show me',
none: 'કઈ નહીં',
},
@@ -127,6 +129,8 @@ export const gu: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -152,7 +156,12 @@ export const gu: LanguageTranslation = {
no_comments: 'કોઈ ટિપ્પણીઓ નથી',
delete_field: 'ફીલ્ડ કાઢી નાખો',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'ચોકસાઈ',
scale: 'માપ',
},
index_actions: {
title: 'ઇન્ડેક્સ લક્ષણો',
@@ -252,9 +261,12 @@ export const gu: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -269,7 +281,13 @@ export const gu: LanguageTranslation = {
undo: 'અનડુ',
redo: 'રીડુ',
reorder_diagram: 'ડાયાગ્રામ ફરીથી વ્યવસ્થિત કરો',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'ઓવરલેપ કરતો ટેબલ હાઇલાઇટ કરો',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -401,6 +419,14 @@ export const gu: LanguageTranslation = {
confirm: 'બદલો',
},
create_table_schema_dialog: {
title: 'નવું સ્કીમા બનાવો',
description:
'હજી સુધી કોઈ સ્કીમા અસ્તિત્વમાં નથી. તમારા ટેબલ્સ ને વ્યવસ્થિત કરવા માટે તમારું પહેલું સ્કીમા બનાવો.',
create: 'બનાવો',
cancel: 'રદ કરો',
},
star_us_dialog: {
title: 'અમને સુધારવામાં મદદ કરો!',
description:

View File

@@ -26,6 +26,8 @@ export const hi: LanguageTranslation = {
hide_sidebar: 'साइडबार छिपाएँ',
hide_cardinality: 'कार्डिनैलिटी छिपाएँ',
show_cardinality: 'कार्डिनैलिटी दिखाएँ',
hide_field_attributes: 'फ़ील्ड विशेषताएँ छिपाएँ',
show_field_attributes: 'फ़ील्ड विशेषताएँ दिखाएँ',
zoom_on_scroll: 'स्क्रॉल पर ज़ूम',
theme: 'थीम',
show_dependencies: 'निर्भरता दिखाएँ',
@@ -74,8 +76,8 @@ export const hi: LanguageTranslation = {
title: 'एकाधिक स्कीमा',
description:
'{{schemasCount}} स्कीमा इस आरेख में हैं। वर्तमान में प्रदर्शित: {{formattedSchemas}}।',
dont_show_again: 'फिर से न दिखाएँ',
change_schema: 'बदलें',
// TODO: Translate
show_me: 'Show me',
none: 'कोई नहीं',
},
@@ -127,6 +129,8 @@ export const hi: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -151,7 +155,12 @@ export const hi: LanguageTranslation = {
no_comments: 'कोई टिप्पणी नहीं',
delete_field: 'फ़ील्ड हटाएँ',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'Precision',
scale: 'Scale',
},
index_actions: {
title: 'सूचकांक विशेषताएँ',
@@ -252,9 +261,12 @@ export const hi: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -269,7 +281,13 @@ export const hi: LanguageTranslation = {
undo: 'पूर्ववत करें',
redo: 'पुनः करें',
reorder_diagram: 'आरेख पुनः व्यवस्थित करें',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'ओवरलैपिंग तालिकाओं को हाइलाइट करें',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -404,6 +422,14 @@ export const hi: LanguageTranslation = {
confirm: 'बदलें',
},
create_table_schema_dialog: {
title: 'नया स्कीमा बनाएं',
description:
'अभी तक कोई स्कीमा मौजूद नहीं है। अपनी तालिकाओं को व्यवस्थित करने के लिए अपना पहला स्कीमा बनाएं।',
create: 'बनाएं',
cancel: 'रद्द करें',
},
star_us_dialog: {
title: 'हमें सुधारने में मदद करें!',
description:

505
src/i18n/locales/hr.ts Normal file
View File

@@ -0,0 +1,505 @@
import type { LanguageMetadata, LanguageTranslation } from '../types';
export const hr: LanguageTranslation = {
translation: {
menu: {
file: {
file: 'Datoteka',
new: 'Nova',
open: 'Otvori',
save: 'Spremi',
import: 'Uvezi',
export_sql: 'Izvezi SQL',
export_as: 'Izvezi kao',
delete_diagram: 'Izbriši dijagram',
exit: 'Izađi',
},
edit: {
edit: 'Uredi',
undo: 'Poništi',
redo: 'Ponovi',
clear: 'Očisti',
},
view: {
view: 'Prikaz',
show_sidebar: 'Prikaži bočnu traku',
hide_sidebar: 'Sakrij bočnu traku',
hide_cardinality: 'Sakrij kardinalnost',
show_cardinality: 'Prikaži kardinalnost',
hide_field_attributes: 'Sakrij atribute polja',
show_field_attributes: 'Prikaži atribute polja',
zoom_on_scroll: 'Zumiranje pri skrolanju',
theme: 'Tema',
show_dependencies: 'Prikaži ovisnosti',
hide_dependencies: 'Sakrij ovisnosti',
show_minimap: 'Prikaži mini kartu',
hide_minimap: 'Sakrij mini kartu',
},
backup: {
backup: 'Sigurnosna kopija',
export_diagram: 'Izvezi dijagram',
restore_diagram: 'Vrati dijagram',
},
help: {
help: 'Pomoć',
docs_website: 'Dokumentacija',
join_discord: 'Pridružite nam se na Discordu',
},
},
delete_diagram_alert: {
title: 'Izbriši dijagram',
description:
'Ova radnja se ne može poništiti. Ovo će trajno izbrisati dijagram.',
cancel: 'Odustani',
delete: 'Izbriši',
},
clear_diagram_alert: {
title: 'Očisti dijagram',
description:
'Ova radnja se ne može poništiti. Ovo će trajno izbrisati sve podatke u dijagramu.',
cancel: 'Odustani',
clear: 'Očisti',
},
reorder_diagram_alert: {
title: 'Preuredi dijagram',
description:
'Ova radnja će preurediti sve tablice u dijagramu. Želite li nastaviti?',
reorder: 'Preuredi',
cancel: 'Odustani',
},
multiple_schemas_alert: {
title: 'Više shema',
description:
'{{schemasCount}} shema u ovom dijagramu. Trenutno prikazano: {{formattedSchemas}}.',
show_me: 'Prikaži mi',
none: 'nijedna',
},
copy_to_clipboard_toast: {
unsupported: {
title: 'Kopiranje neuspješno',
description: 'Međuspremnik nije podržan.',
},
failed: {
title: 'Kopiranje neuspješno',
description: 'Nešto je pošlo po zlu. Molimo pokušajte ponovno.',
},
},
theme: {
system: 'Sustav',
light: 'Svijetla',
dark: 'Tamna',
},
zoom: {
on: 'Uključeno',
off: 'Isključeno',
},
last_saved: 'Zadnje spremljeno',
saved: 'Spremljeno',
loading_diagram: 'Učitavanje dijagrama...',
deselect_all: 'Odznači sve',
select_all: 'Označi sve',
clear: 'Očisti',
show_more: 'Prikaži više',
show_less: 'Prikaži manje',
copy_to_clipboard: 'Kopiraj u međuspremnik',
copied: 'Kopirano!',
side_panel: {
schema: 'Shema:',
filter_by_schema: 'Filtriraj po shemi',
search_schema: 'Pretraži shemu...',
no_schemas_found: 'Nema pronađenih shema.',
view_all_options: 'Prikaži sve opcije...',
tables_section: {
tables: 'Tablice',
add_table: 'Dodaj tablicu',
filter: 'Filtriraj',
collapse: 'Sažmi sve',
clear: 'Očisti filter',
no_results:
'Nema pronađenih tablica koje odgovaraju vašem filteru.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
show_list: 'Prikaži popis tablica',
show_dbml: 'Prikaži DBML uređivač',
table: {
fields: 'Polja',
nullable: 'Može biti null?',
primary_key: 'Primarni ključ',
indexes: 'Indeksi',
comments: 'Komentari',
no_comments: 'Nema komentara',
add_field: 'Dodaj polje',
add_index: 'Dodaj indeks',
index_select_fields: 'Odaberi polja',
no_types_found: 'Nema pronađenih tipova',
field_name: 'Naziv',
field_type: 'Tip',
field_actions: {
title: 'Atributi polja',
unique: 'Jedinstven',
character_length: 'Maksimalna dužina',
precision: 'Preciznost',
scale: 'Skala',
comments: 'Komentari',
no_comments: 'Nema komentara',
default_value: 'Zadana vrijednost',
no_default: 'Nema zadane vrijednosti',
delete_field: 'Izbriši polje',
},
index_actions: {
title: 'Atributi indeksa',
name: 'Naziv',
unique: 'Jedinstven',
delete_index: 'Izbriši indeks',
},
table_actions: {
title: 'Radnje nad tablicom',
change_schema: 'Promijeni shemu',
add_field: 'Dodaj polje',
add_index: 'Dodaj indeks',
duplicate_table: 'Dupliciraj tablicu',
delete_table: 'Izbriši tablicu',
},
},
empty_state: {
title: 'Nema tablica',
description: 'Stvorite tablicu za početak',
},
},
relationships_section: {
relationships: 'Veze',
filter: 'Filtriraj',
add_relationship: 'Dodaj vezu',
collapse: 'Sažmi sve',
relationship: {
primary: 'Primarna tablica',
foreign: 'Referentna tablica',
cardinality: 'Kardinalnost',
delete_relationship: 'Izbriši',
relationship_actions: {
title: 'Radnje',
delete_relationship: 'Izbriši',
},
},
empty_state: {
title: 'Nema veza',
description: 'Stvorite vezu za povezivanje tablica',
},
},
dependencies_section: {
dependencies: 'Ovisnosti',
filter: 'Filtriraj',
collapse: 'Sažmi sve',
dependency: {
table: 'Tablica',
dependent_table: 'Ovisni pogled',
delete_dependency: 'Izbriši',
dependency_actions: {
title: 'Radnje',
delete_dependency: 'Izbriši',
},
},
empty_state: {
title: 'Nema ovisnosti',
description: 'Stvorite pogled za početak',
},
},
areas_section: {
areas: 'Područja',
add_area: 'Dodaj područje',
filter: 'Filtriraj',
clear: 'Očisti filter',
no_results:
'Nema pronađenih područja koja odgovaraju vašem filteru.',
area: {
area_actions: {
title: 'Radnje nad područjem',
edit_name: 'Uredi naziv',
delete_area: 'Izbriši područje',
},
},
empty_state: {
title: 'Nema područja',
description: 'Stvorite područje za početak',
},
},
custom_types_section: {
custom_types: 'Prilagođeni tipovi',
filter: 'Filtriraj',
clear: 'Očisti filter',
no_results:
'Nema pronađenih prilagođenih tipova koji odgovaraju vašem filteru.',
empty_state: {
title: 'Nema prilagođenih tipova',
description:
'Prilagođeni tipovi će se pojaviti ovdje kada budu dostupni u vašoj bazi podataka',
},
custom_type: {
kind: 'Vrsta',
enum_values: 'Enum vrijednosti',
composite_fields: 'Polja',
no_fields: 'Nema definiranih polja',
field_name_placeholder: 'Naziv polja',
field_type_placeholder: 'Odaberi tip',
add_field: 'Dodaj polje',
no_fields_tooltip:
'Nema definiranih polja za ovaj prilagođeni tip',
custom_type_actions: {
title: 'Radnje',
highlight_fields: 'Istakni polja',
clear_field_highlight: 'Ukloni isticanje',
delete_custom_type: 'Izbriši',
},
delete_custom_type: 'Izbriši tip',
},
},
},
toolbar: {
zoom_in: 'Uvećaj',
zoom_out: 'Smanji',
save: 'Spremi',
show_all: 'Prikaži sve',
undo: 'Poništi',
redo: 'Ponovi',
reorder_diagram: 'Preuredi dijagram',
highlight_overlapping_tables: 'Istakni preklapajuće tablice',
clear_custom_type_highlight: 'Ukloni isticanje za "{{typeName}}"',
custom_type_highlight_tooltip:
'Isticanje "{{typeName}}" - Kliknite za uklanjanje',
filter: 'Filtriraj tablice',
},
new_diagram_dialog: {
database_selection: {
title: 'Koja je vaša baza podataka?',
description:
'Svaka baza podataka ima svoje jedinstvene značajke i mogućnosti.',
check_examples_long: 'Pogledaj primjere',
check_examples_short: 'Primjeri',
},
import_database: {
title: 'Uvezite svoju bazu podataka',
database_edition: 'Verzija baze podataka:',
step_1: 'Pokrenite ovu skriptu u svojoj bazi podataka:',
step_2: 'Zalijepite rezultat skripte u ovaj dio →',
script_results_placeholder: 'Rezultati skripte ovdje...',
ssms_instructions: {
button_text: 'SSMS upute',
title: 'Upute',
step_1: 'Idite na Tools > Options > Query Results > SQL Server.',
step_2: 'Ako koristite "Results to Grid," promijenite Maximum Characters Retrieved za Non-XML podatke (postavite na 9999999).',
},
instructions_link: 'Trebate pomoć? Pogledajte kako',
check_script_result: 'Provjeri rezultat skripte',
},
cancel: 'Odustani',
import_from_file: 'Uvezi iz datoteke',
back: 'Natrag',
empty_diagram: 'Prazan dijagram',
continue: 'Nastavi',
import: 'Uvezi',
},
open_diagram_dialog: {
title: 'Otvori dijagram',
description: 'Odaberite dijagram za otvaranje iz popisa ispod.',
table_columns: {
name: 'Naziv',
created_at: 'Stvoreno',
last_modified: 'Zadnje izmijenjeno',
tables_count: 'Tablice',
},
cancel: 'Odustani',
open: 'Otvori',
},
export_sql_dialog: {
title: 'Izvezi SQL',
description:
'Izvezite shemu vašeg dijagrama u {{databaseType}} skriptu',
close: 'Zatvori',
loading: {
text: 'AI generira SQL za {{databaseType}}...',
description: 'Ovo bi trebalo potrajati do 30 sekundi.',
},
error: {
message:
'Greška pri generiranju SQL skripte. Molimo pokušajte ponovno kasnije ili <0>kontaktirajte nas</0>.',
description:
'Slobodno koristite svoj OPENAI_TOKEN, pogledajte priručnik <0>ovdje</0>.',
},
},
create_relationship_dialog: {
title: 'Kreiraj vezu',
primary_table: 'Primarna tablica',
primary_field: 'Primarno polje',
referenced_table: 'Referentna tablica',
referenced_field: 'Referentno polje',
primary_table_placeholder: 'Odaberi tablicu',
primary_field_placeholder: 'Odaberi polje',
referenced_table_placeholder: 'Odaberi tablicu',
referenced_field_placeholder: 'Odaberi polje',
no_tables_found: 'Nema pronađenih tablica',
no_fields_found: 'Nema pronađenih polja',
create: 'Kreiraj',
cancel: 'Odustani',
},
import_database_dialog: {
title: 'Uvezi u trenutni dijagram',
override_alert: {
title: 'Uvezi bazu podataka',
content: {
alert: 'Uvoz ovog dijagrama će utjecati na postojeće tablice i veze.',
new_tables:
'<bold>{{newTablesNumber}}</bold> novih tablica će biti dodano.',
new_relationships:
'<bold>{{newRelationshipsNumber}}</bold> novih veza će biti stvoreno.',
tables_override:
'<bold>{{tablesOverrideNumber}}</bold> tablica će biti prepisano.',
proceed: 'Želite li nastaviti?',
},
import: 'Uvezi',
cancel: 'Odustani',
},
},
export_image_dialog: {
title: 'Izvezi sliku',
description: 'Odaberite faktor veličine za izvoz:',
scale_1x: '1x Obično',
scale_2x: '2x (Preporučeno)',
scale_3x: '3x',
scale_4x: '4x',
cancel: 'Odustani',
export: 'Izvezi',
advanced_options: 'Napredne opcije',
pattern: 'Uključi pozadinski uzorak',
pattern_description: 'Dodaj suptilni mrežni uzorak u pozadinu.',
transparent: 'Prozirna pozadina',
transparent_description: 'Ukloni boju pozadine iz slike.',
},
new_table_schema_dialog: {
title: 'Odaberi shemu',
description:
'Trenutno je prikazano više shema. Odaberite jednu za novu tablicu.',
cancel: 'Odustani',
confirm: 'Potvrdi',
},
update_table_schema_dialog: {
title: 'Promijeni shemu',
description: 'Ažuriraj shemu tablice "{{tableName}}"',
cancel: 'Odustani',
confirm: 'Promijeni',
},
create_table_schema_dialog: {
title: 'Stvori novu shemu',
description:
'Još ne postoje sheme. Stvorite svoju prvu shemu za organiziranje tablica.',
create: 'Stvori',
cancel: 'Odustani',
},
star_us_dialog: {
title: 'Pomozite nam da se poboljšamo!',
description:
'Želite li nam dati zvjezdicu na GitHubu? Samo je jedan klik!',
close: 'Ne sada',
confirm: 'Naravno!',
},
export_diagram_dialog: {
title: 'Izvezi dijagram',
description: 'Odaberite format za izvoz:',
format_json: 'JSON',
cancel: 'Odustani',
export: 'Izvezi',
error: {
title: 'Greška pri izvozu dijagrama',
description:
'Nešto je pošlo po zlu. Trebate pomoć? support@chartdb.io',
},
},
import_diagram_dialog: {
title: 'Uvezi dijagram',
description: 'Uvezite dijagram iz JSON datoteke.',
cancel: 'Odustani',
import: 'Uvezi',
error: {
title: 'Greška pri uvozu dijagrama',
description:
'JSON dijagrama je nevažeći. Molimo provjerite JSON i pokušajte ponovno. Trebate pomoć? support@chartdb.io',
},
},
import_dbml_dialog: {
example_title: 'Uvezi primjer DBML-a',
title: 'Uvezi DBML',
description: 'Uvezite shemu baze podataka iz DBML formata.',
import: 'Uvezi',
cancel: 'Odustani',
skip_and_empty: 'Preskoči i isprazni',
show_example: 'Prikaži primjer',
error: {
title: 'Greška pri uvozu DBML-a',
description:
'Neuspješno parsiranje DBML-a. Molimo provjerite sintaksu.',
},
},
relationship_type: {
one_to_one: 'Jedan na jedan',
one_to_many: 'Jedan na više',
many_to_one: 'Više na jedan',
many_to_many: 'Više na više',
},
canvas_context_menu: {
new_table: 'Nova tablica',
new_relationship: 'Nova veza',
new_area: 'Novo područje',
},
table_node_context_menu: {
edit_table: 'Uredi tablicu',
duplicate_table: 'Dupliciraj tablicu',
delete_table: 'Izbriši tablicu',
add_relationship: 'Dodaj vezu',
},
snap_to_grid_tooltip: 'Priljepljivanje na mrežu (Drži {{key}})',
tool_tips: {
double_click_to_edit: 'Dvostruki klik za uređivanje',
},
language_select: {
change_language: 'Jezik',
},
},
};
export const hrMetadata: LanguageMetadata = {
name: 'Croatian',
nativeName: 'Hrvatski',
code: 'hr',
};

View File

@@ -26,6 +26,8 @@ export const id_ID: LanguageTranslation = {
hide_sidebar: 'Sembunyikan Sidebar',
hide_cardinality: 'Sembunyikan Kardinalitas',
show_cardinality: 'Tampilkan Kardinalitas',
hide_field_attributes: 'Sembunyikan Atribut Kolom',
show_field_attributes: 'Tampilkan Atribut Kolom',
zoom_on_scroll: 'Perbesar saat Scroll',
theme: 'Tema',
show_dependencies: 'Tampilkan Dependensi',
@@ -74,8 +76,8 @@ export const id_ID: LanguageTranslation = {
title: 'Schema Lebih dari satu',
description:
'{{schemasCount}} schema di diagram ini. Sedang ditampilkan: {{formattedSchemas}}.',
dont_show_again: 'Jangan tampilkan lagi',
change_schema: 'Ubah',
// TODO: Translate
show_me: 'Show me',
none: 'Tidak ada',
},
@@ -126,6 +128,8 @@ export const id_ID: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -150,7 +154,12 @@ export const id_ID: LanguageTranslation = {
no_comments: 'Tidak ada komentar',
delete_field: 'Hapus Kolom',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'Presisi',
scale: 'Skala',
},
index_actions: {
title: 'Atribut Indeks',
@@ -250,9 +259,12 @@ export const id_ID: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -267,7 +279,13 @@ export const id_ID: LanguageTranslation = {
undo: 'Undo',
redo: 'Redo',
reorder_diagram: 'Atur Ulang Diagram',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'Sorot Tabel yang Tumpang Tindih',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -399,6 +417,14 @@ export const id_ID: LanguageTranslation = {
confirm: 'Ubah',
},
create_table_schema_dialog: {
title: 'Buat Skema Baru',
description:
'Belum ada skema yang tersedia. Buat skema pertama Anda untuk mengatur tabel-tabel Anda.',
create: 'Buat',
cancel: 'Batal',
},
star_us_dialog: {
title: 'Bantu kami meningkatkan!',
description:

View File

@@ -26,6 +26,8 @@ export const ja: LanguageTranslation = {
hide_sidebar: 'サイドバーを非表示',
hide_cardinality: 'カーディナリティを非表示',
show_cardinality: 'カーディナリティを表示',
hide_field_attributes: 'フィールド属性を非表示',
show_field_attributes: 'フィールド属性を表示',
zoom_on_scroll: 'スクロールでズーム',
theme: 'テーマ',
// TODO: Translate
@@ -76,8 +78,8 @@ export const ja: LanguageTranslation = {
title: '複数のスキーマ',
description:
'このダイアグラムには{{schemasCount}}個のスキーマがあります。現在表示中: {{formattedSchemas}}。',
dont_show_again: '再表示しない',
change_schema: '変更',
// TODO: Translate
show_me: 'Show me',
none: 'なし',
},
@@ -130,6 +132,8 @@ export const ja: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -154,7 +158,12 @@ export const ja: LanguageTranslation = {
no_comments: 'コメントがありません',
delete_field: 'フィールドを削除',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: '精度',
scale: '小数点以下桁数',
},
index_actions: {
title: 'インデックス属性',
@@ -256,9 +265,12 @@ export const ja: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -275,6 +287,10 @@ export const ja: LanguageTranslation = {
reorder_diagram: 'ダイアグラムを並べ替え',
// TODO: Translate
highlight_overlapping_tables: 'Highlight Overlapping Tables',
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear', // TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -408,6 +424,14 @@ export const ja: LanguageTranslation = {
confirm: '変更',
},
create_table_schema_dialog: {
title: '新しいスキーマを作成',
description:
'スキーマがまだ存在しません。テーブルを整理するために最初のスキーマを作成してください。',
create: '作成',
cancel: 'キャンセル',
},
star_us_dialog: {
title: '改善をサポートしてください!',
description:

View File

@@ -26,6 +26,8 @@ export const ko_KR: LanguageTranslation = {
hide_sidebar: '사이드바 숨기기',
hide_cardinality: '카디널리티 숨기기',
show_cardinality: '카디널리티 보이기',
hide_field_attributes: '필드 속성 숨기기',
show_field_attributes: '필드 속성 보이기',
zoom_on_scroll: '스크롤 시 확대',
theme: '테마',
show_dependencies: '종속성 보이기',
@@ -74,8 +76,8 @@ export const ko_KR: LanguageTranslation = {
title: '다중 스키마',
description:
'현재 다이어그램에 {{schemasCount}}개의 스키마가 있습니다. Currently displaying: {{formattedSchemas}}.',
dont_show_again: '다시 보여주지 마세요',
change_schema: '변경',
// TODO: Translate
show_me: 'Show me',
none: '없음',
},
@@ -126,6 +128,8 @@ export const ko_KR: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -150,7 +154,12 @@ export const ko_KR: LanguageTranslation = {
no_comments: '주석 없음',
delete_field: '필드 삭제',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: '정밀도',
scale: '소수점 자릿수',
},
index_actions: {
title: '인덱스 속성',
@@ -250,9 +259,12 @@ export const ko_KR: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -267,7 +279,13 @@ export const ko_KR: LanguageTranslation = {
undo: '실행 취소',
redo: '다시 실행',
reorder_diagram: '다이어그램 재정렬',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: '겹치는 테이블 강조 표시',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -399,6 +417,14 @@ export const ko_KR: LanguageTranslation = {
confirm: '변경',
},
create_table_schema_dialog: {
title: '새 스키마 생성',
description:
'아직 스키마가 없습니다. 테이블을 정리하기 위해 첫 번째 스키마를 생성하세요.',
create: '생성',
cancel: '취소',
},
star_us_dialog: {
title: '개선할 수 있도록 도와주세요!',
description:

View File

@@ -26,6 +26,8 @@ export const mr: LanguageTranslation = {
hide_sidebar: 'साइडबार लपवा',
hide_cardinality: 'कार्डिनॅलिटी लपवा',
show_cardinality: 'कार्डिनॅलिटी दाखवा',
hide_field_attributes: 'फील्ड गुणधर्म लपवा',
show_field_attributes: 'फील्ड गुणधर्म दाखवा',
zoom_on_scroll: 'स्क्रोलवर झूम करा',
theme: 'थीम',
show_dependencies: 'डिपेंडेन्सि दाखवा',
@@ -75,8 +77,8 @@ export const mr: LanguageTranslation = {
title: 'एकाधिक स्कीमा',
description:
'{{schemasCount}} स्कीमा या आरेखात आहेत. सध्या दाखवत आहोत: {{formattedSchemas}}.',
dont_show_again: 'पुन्हा दाखवू नका',
change_schema: 'बदला',
// TODO: Translate
show_me: 'Show me',
none: 'काहीही नाही',
},
@@ -129,6 +131,8 @@ export const mr: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -153,7 +157,12 @@ export const mr: LanguageTranslation = {
no_comments: 'कोणत्याही टिप्पणी नाहीत',
delete_field: 'फील्ड हटवा',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'अचूकता',
scale: 'प्रमाण',
},
index_actions: {
title: 'इंडेक्स गुणधर्म',
@@ -255,9 +264,12 @@ export const mr: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -272,7 +284,13 @@ export const mr: LanguageTranslation = {
undo: 'पूर्ववत करा',
redo: 'पुन्हा करा',
reorder_diagram: 'आरेख पुनःक्रमित करा',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'ओव्हरलॅपिंग टेबल्स हायलाइट करा',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -407,6 +425,14 @@ export const mr: LanguageTranslation = {
confirm: 'बदला',
},
create_table_schema_dialog: {
title: 'नवीन स्कीमा तयार करा',
description:
'अजून कोणतीही स्कीमा अस्तित्वात नाही. आपल्या टेबल्स व्यवस्थित करण्यासाठी आपली पहिली स्कीमा तयार करा.',
create: 'तयार करा',
cancel: 'रद्द करा',
},
star_us_dialog: {
title: 'आम्हाला सुधारण्यास मदत करा!',
description:

View File

@@ -26,6 +26,8 @@ export const ne: LanguageTranslation = {
hide_sidebar: 'साइडबार लुकाउनुहोस्',
hide_cardinality: 'कार्डिन्यालिटी लुकाउनुहोस्',
show_cardinality: 'कार्डिन्यालिटी देखाउनुहोस्',
hide_field_attributes: 'फिल्ड विशेषताहरू लुकाउनुहोस्',
show_field_attributes: 'फिल्ड विशेषताहरू देखाउनुहोस्',
zoom_on_scroll: 'स्क्रोलमा जुम गर्नुहोस्',
theme: 'थिम',
show_dependencies: 'डिपेन्डेन्सीहरू देखाउनुहोस्',
@@ -75,8 +77,8 @@ export const ne: LanguageTranslation = {
title: 'विविध स्कीमहरू',
description:
'{{schemasCount}} डायाग्राममा स्कीमहरू। हालको रूपमा देखाइएको छ: {{formattedSchemas}}।',
dont_show_again: 'फेरि देखाउन नदिनुहोस्',
change_schema: 'स्कीम परिवर्तन गर्नुहोस्',
// TODO: Translate
show_me: 'Show me',
none: 'कुनै पनि छैन',
},
@@ -128,6 +130,9 @@ export const ne: LanguageTranslation = {
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
// TODO: Translate
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -151,7 +156,12 @@ export const ne: LanguageTranslation = {
no_comments: 'कुनै टिप्पणीहरू छैनन्',
delete_field: 'क्षेत्र हटाउनुहोस्',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'परिशुद्धता',
scale: 'स्केल',
},
index_actions: {
title: 'सूचक विशेषताहरू',
@@ -252,9 +262,12 @@ export const ne: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -269,8 +282,14 @@ export const ne: LanguageTranslation = {
undo: 'पूर्ववत',
redo: 'पुनः गर्नुहोस्',
reorder_diagram: 'पुनः क्रमबद्ध गर्नुहोस्',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables:
'अतिरिक्त तालिकाहरू हाइलाइट गर्नुहोस्',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -404,6 +423,14 @@ export const ne: LanguageTranslation = {
confirm: 'परिवर्तन गर्नुहोस्',
},
create_table_schema_dialog: {
title: 'नयाँ स्कीम सिर्जना गर्नुहोस्',
description:
'अहिलेसम्म कुनै स्कीम अस्तित्वमा छैन। आफ्ना तालिकाहरू व्यवस्थित गर्न आफ्नो पहिलो स्कीम सिर्जना गर्नुहोस्।',
create: 'सिर्जना गर्नुहोस्',
cancel: 'रद्द गर्नुहोस्',
},
star_us_dialog: {
title: 'हामीलाई अझ राम्रो हुन मदत गर्नुहोस!',
description:

View File

@@ -26,6 +26,8 @@ export const pt_BR: LanguageTranslation = {
hide_sidebar: 'Ocultar Barra Lateral',
hide_cardinality: 'Ocultar Cardinalidade',
show_cardinality: 'Mostrar Cardinalidade',
hide_field_attributes: 'Ocultar Atributos de Campo',
show_field_attributes: 'Mostrar Atributos de Campo',
zoom_on_scroll: 'Zoom ao Rolar',
theme: 'Tema',
show_dependencies: 'Mostrar Dependências',
@@ -75,8 +77,8 @@ export const pt_BR: LanguageTranslation = {
title: 'Múltiplos Esquemas',
description:
'{{schemasCount}} esquemas neste diagrama. Atualmente exibindo: {{formattedSchemas}}.',
dont_show_again: 'Não mostrar novamente',
change_schema: 'Alterar',
// TODO: Translate
show_me: 'Show me',
none: 'nenhum',
},
@@ -127,6 +129,8 @@ export const pt_BR: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -151,7 +155,12 @@ export const pt_BR: LanguageTranslation = {
no_comments: 'Sem comentários',
delete_field: 'Excluir Campo',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'Precisão',
scale: 'Escala',
},
index_actions: {
title: 'Atributos do Índice',
@@ -251,9 +260,12 @@ export const pt_BR: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -268,7 +280,13 @@ export const pt_BR: LanguageTranslation = {
undo: 'Desfazer',
redo: 'Refazer',
reorder_diagram: 'Reordenar Diagrama',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'Destacar Tabelas Sobrepostas',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -402,6 +420,14 @@ export const pt_BR: LanguageTranslation = {
confirm: 'Alterar',
},
create_table_schema_dialog: {
title: 'Criar Novo Esquema',
description:
'Ainda não existem esquemas. Crie seu primeiro esquema para organizar suas tabelas.',
create: 'Criar',
cancel: 'Cancelar',
},
star_us_dialog: {
title: 'Ajude-nos a melhorar!',
description:

View File

@@ -26,6 +26,8 @@ export const ru: LanguageTranslation = {
hide_sidebar: 'Скрыть боковую панель',
hide_cardinality: 'Скрыть виды связи',
show_cardinality: 'Показать виды связи',
show_field_attributes: 'Показать атрибуты поля',
hide_field_attributes: 'Скрыть атрибуты поля',
zoom_on_scroll: 'Увеличение при прокрутке',
theme: 'Тема',
show_dependencies: 'Показать зависимости',
@@ -73,8 +75,8 @@ export const ru: LanguageTranslation = {
title: 'Множественные схемы',
description:
'{{schemasCount}} схем в этой диаграмме. В данный момент отображается: {{formattedSchemas}}.',
dont_show_again: 'Больше не показывать',
change_schema: 'Изменить',
// TODO: Translate
show_me: 'Show me',
none: 'никто',
},
@@ -125,6 +127,8 @@ export const ru: LanguageTranslation = {
no_results:
'Таблицы не найдены, соответствующие вашему фильтру.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
show_list: 'Переключиться на список таблиц',
show_dbml: 'Переключиться на редактор DBML',
@@ -147,7 +151,12 @@ export const ru: LanguageTranslation = {
comments: 'Комментарии',
no_comments: 'Нет комментария',
delete_field: 'Удалить поле',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
character_length: 'Макс. длина',
precision: 'Точность',
scale: 'Масштаб',
},
index_actions: {
title: 'Атрибуты индекса',
@@ -248,9 +257,12 @@ export const ru: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -265,7 +277,13 @@ export const ru: LanguageTranslation = {
undo: 'Отменить',
redo: 'Вернуть',
reorder_diagram: 'Переупорядочить диаграмму',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'Выделение перекрывающихся таблиц',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -399,6 +417,14 @@ export const ru: LanguageTranslation = {
confirm: 'Изменить',
},
create_table_schema_dialog: {
title: 'Создать новую схему',
description:
'Схемы еще не существуют. Создайте вашу первую схему, чтобы организовать таблицы.',
create: 'Создать',
cancel: 'Отменить',
},
star_us_dialog: {
title: 'Помогите нам стать лучше!',
description:

View File

@@ -26,6 +26,8 @@ export const te: LanguageTranslation = {
hide_sidebar: 'సైడ్‌బార్ దాచండి',
hide_cardinality: 'కార్డినాలిటీని దాచండి',
show_cardinality: 'కార్డినాలిటీని చూపించండి',
show_field_attributes: 'ఫీల్డ్ గుణాలను చూపించు',
hide_field_attributes: 'ఫీల్డ్ గుణాలను దాచండి',
zoom_on_scroll: 'స్క్రోల్‌పై జూమ్',
theme: 'థీమ్',
show_dependencies: 'ఆధారాలు చూపించండి',
@@ -75,8 +77,8 @@ export const te: LanguageTranslation = {
title: 'బహుళ స్కీమాలు',
description:
'{{schemasCount}} స్కీమాలు ఈ చిత్రంలో ఉన్నాయి. ప్రస్తుత స్కీమాలు: {{formattedSchemas}}.',
dont_show_again: 'మరలా చూపించవద్దు',
change_schema: 'మార్చు',
// TODO: Translate
show_me: 'Show me',
none: 'ఎదరికాదు',
},
@@ -127,6 +129,8 @@ export const te: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -151,7 +155,12 @@ export const te: LanguageTranslation = {
no_comments: 'వ్యాఖ్యలు లేవు',
delete_field: 'ఫీల్డ్ తొలగించు',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'సూక్ష్మత',
scale: 'స్కేల్',
},
index_actions: {
title: 'ఇండెక్స్ గుణాలు',
@@ -252,9 +261,12 @@ export const te: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -269,7 +281,13 @@ export const te: LanguageTranslation = {
undo: 'తిరిగి చేయు',
redo: 'మరలా చేయు',
reorder_diagram: 'చిత్రాన్ని పునఃసరిచేయండి',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'అవకాశించు పట్టికలను హైలైట్ చేయండి',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -403,6 +421,14 @@ export const te: LanguageTranslation = {
confirm: 'మార్చు',
},
create_table_schema_dialog: {
title: 'కొత్త స్కీమా సృష్టించండి',
description:
'ఇంకా ఏ స్కీమాలు లేవు. మీ పట్టికలను వ్యవస్థీకరించడానికి మీ మొదటి స్కీమాను సృష్టించండి.',
create: 'సృష్టించు',
cancel: 'రద్దు',
},
star_us_dialog: {
title: 'మా సహాయంతో మెరుగుపరచండి!',
description:

View File

@@ -26,6 +26,8 @@ export const tr: LanguageTranslation = {
hide_sidebar: 'Kenar Çubuğunu Gizle',
hide_cardinality: 'Kardinaliteyi Gizle',
show_cardinality: 'Kardinaliteyi Göster',
show_field_attributes: 'Alan Özelliklerini Göster',
hide_field_attributes: 'Alan Özelliklerini Gizle',
zoom_on_scroll: 'Kaydırarak Yakınlaştır',
theme: 'Tema',
show_dependencies: 'Bağımlılıkları Göster',
@@ -75,8 +77,8 @@ export const tr: LanguageTranslation = {
title: 'Birden Fazla Şema',
description:
'Bu diyagramda {{schemasCount}} şema var. Şu anda görüntülenen: {{formattedSchemas}}.',
dont_show_again: 'Tekrar gösterme',
change_schema: 'Değiştir',
// TODO: Translate
show_me: 'Show me',
none: 'yok',
},
@@ -126,6 +128,8 @@ export const tr: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -150,7 +154,12 @@ export const tr: LanguageTranslation = {
no_comments: 'Yorum yok',
delete_field: 'Alanı Sil',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'Hassasiyet',
scale: 'Ölçek',
},
index_actions: {
title: 'İndeks Özellikleri',
@@ -251,9 +260,12 @@ export const tr: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -267,7 +279,13 @@ export const tr: LanguageTranslation = {
undo: 'Geri Al',
redo: 'Yinele',
reorder_diagram: 'Diyagramı Yeniden Sırala',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'Çakışan Tabloları Vurgula',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
database_selection: {
@@ -392,6 +410,14 @@ export const tr: LanguageTranslation = {
cancel: 'İptal',
confirm: 'Değiştir',
},
create_table_schema_dialog: {
title: 'Yeni Şema Oluştur',
description:
'Henüz hiç şema mevcut değil. Tablolarınızı düzenlemek için ilk şemanızı oluşturun.',
create: 'Oluştur',
cancel: 'İptal',
},
star_us_dialog: {
title: 'Bize yardım et!',
description:

View File

@@ -26,6 +26,8 @@ export const uk: LanguageTranslation = {
hide_sidebar: 'Приховати бічну панель',
hide_cardinality: 'Приховати потужність',
show_cardinality: 'Показати кардинальність',
show_field_attributes: 'Показати атрибути полів',
hide_field_attributes: 'Приховати атрибути полів',
zoom_on_scroll: 'Масштабувати прокручуванням',
theme: 'Тема',
show_dependencies: 'Показати залежності',
@@ -73,8 +75,8 @@ export const uk: LanguageTranslation = {
title: 'Кілька схем',
description:
'{{schemasCount}} схеми на цій діаграмі. Зараз відображається: {{formattedSchemas}}.',
dont_show_again: 'Більше не показувати',
change_schema: 'Зміна',
// TODO: Translate
show_me: 'Show me',
none: 'немає',
},
@@ -125,6 +127,8 @@ export const uk: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -149,7 +153,12 @@ export const uk: LanguageTranslation = {
no_comments: 'Немає коментарів',
delete_field: 'Видалити поле',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'Точність',
scale: 'Масштаб',
},
index_actions: {
title: 'Атрибути індексу',
@@ -249,9 +258,12 @@ export const uk: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -266,7 +278,13 @@ export const uk: LanguageTranslation = {
undo: 'Скасувати',
redo: 'Повторити',
reorder_diagram: 'Перевпорядкувати діаграму',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'Показати таблиці, що перекриваються',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -400,6 +418,14 @@ export const uk: LanguageTranslation = {
confirm: 'Змінити',
},
create_table_schema_dialog: {
title: 'Створити нову схему',
description:
'Поки що не існує жодної схеми. Створіть свою першу схему, щоб організувати ваші таблиці.',
create: 'Створити',
cancel: 'Скасувати',
},
star_us_dialog: {
title: 'Допоможіть нам покращитися!',
description: 'Поставне на зірку на GitHub? Це лише один клік!',

View File

@@ -26,6 +26,8 @@ export const vi: LanguageTranslation = {
hide_sidebar: 'Ẩn thanh bên',
hide_cardinality: 'Ẩn số lượng',
show_cardinality: 'Hiển thị số lượng',
show_field_attributes: 'Hiển thị thuộc tính trường',
hide_field_attributes: 'Ẩn thuộc tính trường',
zoom_on_scroll: 'Thu phóng khi cuộn',
theme: 'Chủ đề',
show_dependencies: 'Hiển thị các phụ thuộc',
@@ -74,8 +76,8 @@ export const vi: LanguageTranslation = {
title: 'Có nhiều lược đồ',
description:
'Có {{schemasCount}} lược đồ trong sơ đồ này. Hiện đang hiển thị: {{formattedSchemas}}.',
dont_show_again: 'Không hiển thị lại',
change_schema: 'Thay đổi',
// TODO: Translate
show_me: 'Show me',
none: 'không có',
},
@@ -126,6 +128,8 @@ export const vi: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -150,7 +154,12 @@ export const vi: LanguageTranslation = {
no_comments: 'Không có bình luận',
delete_field: 'Xóa trường',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: 'Độ chính xác',
scale: 'Tỷ lệ',
},
index_actions: {
title: 'Thuộc tính chỉ mục',
@@ -250,9 +259,12 @@ export const vi: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -267,7 +279,13 @@ export const vi: LanguageTranslation = {
undo: 'Hoàn tác',
redo: 'Làm lại',
reorder_diagram: 'Sắp xếp lại sơ đồ',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: 'Làm nổi bật các bảng chồng chéo',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -399,6 +417,14 @@ export const vi: LanguageTranslation = {
confirm: 'Xác nhận',
},
create_table_schema_dialog: {
title: 'Tạo lược đồ mới',
description:
'Chưa có lược đồ nào. Tạo lược đồ đầu tiên của bạn để tổ chức các bảng.',
create: 'Tạo',
cancel: 'Hủy',
},
star_us_dialog: {
title: 'Hãy giúp chúng tôi cải thiện!',
description:

View File

@@ -26,6 +26,8 @@ export const zh_CN: LanguageTranslation = {
hide_sidebar: '隐藏侧边栏',
hide_cardinality: '隐藏基数',
show_cardinality: '展示基数',
show_field_attributes: '展示字段属性',
hide_field_attributes: '隐藏字段属性',
zoom_on_scroll: '滚动缩放',
theme: '主题',
show_dependencies: '展示依赖',
@@ -71,8 +73,8 @@ export const zh_CN: LanguageTranslation = {
title: '多个模式',
description:
'此关系图中有 {{schemasCount}} 个模式,当前显示:{{formattedSchemas}}。',
dont_show_again: '不再展示',
change_schema: '更改',
// TODO: Translate
show_me: 'Show me',
none: '无',
},
@@ -123,6 +125,8 @@ export const zh_CN: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -147,7 +151,12 @@ export const zh_CN: LanguageTranslation = {
no_comments: '空',
delete_field: '删除字段',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: '精度',
scale: '小数位',
},
index_actions: {
title: '索引属性',
@@ -247,9 +256,12 @@ export const zh_CN: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -264,7 +276,13 @@ export const zh_CN: LanguageTranslation = {
undo: '撤销',
redo: '重做',
reorder_diagram: '重新排列关系图',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: '突出显示重叠的表',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -395,6 +413,13 @@ export const zh_CN: LanguageTranslation = {
confirm: '更改',
},
create_table_schema_dialog: {
title: '创建新模式',
description: '尚未存在任何模式。创建您的第一个模式来组织您的表。',
create: '创建',
cancel: '取消',
},
star_us_dialog: {
title: '帮助我们改进!',
description: '您想在 GitHub 上为我们加注星标吗?只需点击一下即可!',

View File

@@ -26,6 +26,8 @@ export const zh_TW: LanguageTranslation = {
hide_sidebar: '隱藏側邊欄',
hide_cardinality: '隱藏基數',
show_cardinality: '顯示基數',
hide_field_attributes: '隱藏欄位屬性',
show_field_attributes: '顯示欄位屬性',
zoom_on_scroll: '滾動縮放',
theme: '主題',
show_dependencies: '顯示相依性',
@@ -71,8 +73,8 @@ export const zh_TW: LanguageTranslation = {
title: '多重 Schema',
description:
'此圖表中包含 {{schemasCount}} 個 Schema目前顯示{{formattedSchemas}}。',
dont_show_again: '不再顯示',
change_schema: '變更',
// TODO: Translate
show_me: 'Show me',
none: '無',
},
@@ -123,6 +125,8 @@ export const zh_TW: LanguageTranslation = {
// TODO: Translate
clear: 'Clear Filter',
no_results: 'No tables found matching your filter.',
all_tables_filtered: 'All tables are filtered.',
open_filter: 'Open Filter',
// TODO: Translate
show_list: 'Show Table List',
show_dbml: 'Show DBML Editor',
@@ -147,7 +151,12 @@ export const zh_TW: LanguageTranslation = {
no_comments: '無註解',
delete_field: '刪除欄位',
// TODO: Translate
default_value: 'Default Value',
no_default: 'No default',
// TODO: Translate
character_length: 'Max Length',
precision: '精度',
scale: '小數位',
},
index_actions: {
title: '索引屬性',
@@ -247,9 +256,12 @@ export const zh_TW: LanguageTranslation = {
field_name_placeholder: 'Field name',
field_type_placeholder: 'Select type',
add_field: 'Add Field',
no_fields_tooltip: 'No fields defined for this custom type',
custom_type_actions: {
title: 'Actions',
highlight_fields: 'Highlight Fields',
delete_custom_type: 'Delete',
clear_field_highlight: 'Clear Highlight',
},
delete_custom_type: 'Delete Type',
},
@@ -264,7 +276,13 @@ export const zh_TW: LanguageTranslation = {
undo: '復原',
redo: '重做',
reorder_diagram: '重新排列圖表',
// TODO: Translate
clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"',
custom_type_highlight_tooltip:
'Highlighting "{{typeName}}" - Click to clear',
highlight_overlapping_tables: '突出顯示重疊表格',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
@@ -394,6 +412,14 @@ export const zh_TW: LanguageTranslation = {
confirm: '變更',
},
create_table_schema_dialog: {
title: '建立新 Schema',
description:
'尚未存在任何 Schema。建立您的第一個 Schema 來組織您的表格。',
create: '建立',
cancel: '取消',
},
star_us_dialog: {
title: '協助我們改善!',
description: '請在 GitHub 上給我們一顆星,只需點擊一下!',

View File

@@ -1,3 +1,4 @@
import type { DBCustomType } from './domain';
import type { Area } from './domain/area';
import type { DBDependency } from './domain/db-dependency';
import type { DBField } from './domain/db-field';
@@ -48,6 +49,10 @@ const generateIdsMapFromDiagram = (
idsMap.set(area.id, generateId());
});
diagram.customTypes?.forEach((customType) => {
idsMap.set(customType.id, generateId());
});
return idsMap;
};
@@ -124,7 +129,7 @@ export const cloneDiagram = (
} = {
generateId: defaultGenerateId,
}
): Diagram => {
): { diagram: Diagram; idsMap: Map<string, string> } => {
const { generateId } = options;
const diagramId = generateId();
@@ -213,14 +218,38 @@ export const cloneDiagram = (
})
.filter((area): area is Area => area !== null) ?? [];
const customTypes: DBCustomType[] =
diagram.customTypes
?.map((customType) => {
const id = getNewId(customType.id);
if (!id) {
return null;
}
return {
...customType,
id,
} satisfies DBCustomType;
})
.filter(
(customType): customType is DBCustomType => customType !== null
) ?? [];
return {
...diagram,
id: diagramId,
dependencies,
relationships,
tables,
areas,
createdAt: new Date(),
updatedAt: new Date(),
diagram: {
...diagram,
id: diagramId,
dependencies,
relationships,
tables,
areas,
customTypes,
createdAt: diagram.createdAt
? new Date(diagram.createdAt)
: new Date(),
updatedAt: diagram.updatedAt
? new Date(diagram.updatedAt)
: new Date(),
},
idsMap,
};
};

View File

@@ -48,18 +48,30 @@ export const clickhouseDataTypes: readonly DataTypeData[] = [
{ name: 'mediumblob', id: 'mediumblob' },
{ name: 'tinyblob', id: 'tinyblob' },
{ name: 'blob', id: 'blob' },
{ name: 'varchar', id: 'varchar', hasCharMaxLength: true },
{ name: 'char', id: 'char', hasCharMaxLength: true },
{
name: 'varchar',
id: 'varchar',
fieldAttributes: { hasCharMaxLength: true },
},
{ name: 'char', id: 'char', fieldAttributes: { hasCharMaxLength: true } },
{ name: 'char large object', id: 'char_large_object' },
{ name: 'char varying', id: 'char_varying', hasCharMaxLength: true },
{
name: 'char varying',
id: 'char_varying',
fieldAttributes: { hasCharMaxLength: true },
},
{ name: 'character large object', id: 'character_large_object' },
{
name: 'character varying',
id: 'character_varying',
hasCharMaxLength: true,
fieldAttributes: { hasCharMaxLength: true },
},
{ name: 'nchar large object', id: 'nchar_large_object' },
{ name: 'nchar varying', id: 'nchar_varying', hasCharMaxLength: true },
{
name: 'nchar varying',
id: 'nchar_varying',
fieldAttributes: { hasCharMaxLength: true },
},
{
name: 'national character large object',
id: 'national_character_large_object',
@@ -67,22 +79,34 @@ export const clickhouseDataTypes: readonly DataTypeData[] = [
{
name: 'national character varying',
id: 'national_character_varying',
hasCharMaxLength: true,
fieldAttributes: { hasCharMaxLength: true },
},
{
name: 'national char varying',
id: 'national_char_varying',
hasCharMaxLength: true,
fieldAttributes: { hasCharMaxLength: true },
},
{
name: 'national character',
id: 'national_character',
hasCharMaxLength: true,
fieldAttributes: { hasCharMaxLength: true },
},
{
name: 'national char',
id: 'national_char',
fieldAttributes: { hasCharMaxLength: true },
},
{ name: 'national char', id: 'national_char', hasCharMaxLength: true },
{ name: 'binary large object', id: 'binary_large_object' },
{ name: 'binary varying', id: 'binary_varying', hasCharMaxLength: true },
{ name: 'fixedstring', id: 'fixedstring', hasCharMaxLength: true },
{
name: 'binary varying',
id: 'binary_varying',
fieldAttributes: { hasCharMaxLength: true },
},
{
name: 'fixedstring',
id: 'fixedstring',
fieldAttributes: { hasCharMaxLength: true },
},
{ name: 'string', id: 'string' },
// Date Types

View File

@@ -14,9 +14,23 @@ export interface DataType {
name: string;
}
export interface DataTypeData extends DataType {
export interface FieldAttributeRange {
max: number;
min: number;
default: number;
}
interface FieldAttributes {
hasCharMaxLength?: boolean;
hasCharMaxLengthOption?: boolean;
precision?: FieldAttributeRange;
scale?: FieldAttributeRange;
maxLength?: number;
}
export interface DataTypeData extends DataType {
usageLevel?: 1 | 2; // Level 1 is most common, Level 2 is second most common
fieldAttributes?: FieldAttributes;
}
export const dataTypeSchema: z.ZodType<DataType> = z.object({

View File

@@ -2,7 +2,12 @@ import type { DataTypeData } from './data-types';
export const genericDataTypes: readonly DataTypeData[] = [
// Level 1 - Most commonly used types
{ name: 'varchar', id: 'varchar', hasCharMaxLength: true, usageLevel: 1 },
{
name: 'varchar',
id: 'varchar',
fieldAttributes: { hasCharMaxLength: true },
usageLevel: 1,
},
{ name: 'int', id: 'int', usageLevel: 1 },
{ name: 'text', id: 'text', usageLevel: 1 },
{ name: 'boolean', id: 'boolean', usageLevel: 1 },
@@ -10,23 +15,62 @@ export const genericDataTypes: readonly DataTypeData[] = [
{ name: 'timestamp', id: 'timestamp', usageLevel: 1 },
// Level 2 - Second most common types
{ name: 'decimal', id: 'decimal', usageLevel: 2 },
{
name: 'decimal',
id: 'decimal',
usageLevel: 2,
fieldAttributes: {
precision: {
max: 999,
min: 1,
default: 10,
},
scale: {
max: 999,
min: 0,
default: 2,
},
},
},
{ name: 'datetime', id: 'datetime', usageLevel: 2 },
{ name: 'json', id: 'json', usageLevel: 2 },
{ name: 'uuid', id: 'uuid', usageLevel: 2 },
// Less common types
{ name: 'bigint', id: 'bigint' },
{ name: 'binary', id: 'binary', hasCharMaxLength: true },
{
name: 'binary',
id: 'binary',
fieldAttributes: { hasCharMaxLength: true },
},
{ name: 'blob', id: 'blob' },
{ name: 'char', id: 'char', hasCharMaxLength: true },
{ name: 'char', id: 'char', fieldAttributes: { hasCharMaxLength: true } },
{ name: 'double', id: 'double' },
{ name: 'enum', id: 'enum' },
{ name: 'float', id: 'float' },
{ name: 'numeric', id: 'numeric' },
{
name: 'numeric',
id: 'numeric',
fieldAttributes: {
precision: {
max: 999,
min: 1,
default: 10,
},
scale: {
max: 999,
min: 0,
default: 2,
},
},
},
{ name: 'real', id: 'real' },
{ name: 'set', id: 'set' },
{ name: 'smallint', id: 'smallint' },
{ name: 'time', id: 'time' },
{ name: 'varbinary', id: 'varbinary', hasCharMaxLength: true },
{
name: 'varbinary',
id: 'varbinary',
fieldAttributes: { hasCharMaxLength: true },
},
] as const;

View File

@@ -4,12 +4,32 @@ export const mariadbDataTypes: readonly DataTypeData[] = [
// Level 1 - Most commonly used types
{ name: 'int', id: 'int', usageLevel: 1 },
{ name: 'bigint', id: 'bigint', usageLevel: 1 },
{ name: 'decimal', id: 'decimal', usageLevel: 1 },
{
name: 'decimal',
id: 'decimal',
usageLevel: 1,
fieldAttributes: {
precision: {
max: 65,
min: 1,
default: 10,
},
scale: {
max: 30,
min: 0,
default: 0,
},
},
},
{ name: 'boolean', id: 'boolean', usageLevel: 1 },
{ name: 'datetime', id: 'datetime', usageLevel: 1 },
{ name: 'date', id: 'date', usageLevel: 1 },
{ name: 'timestamp', id: 'timestamp', usageLevel: 1 },
{ name: 'varchar', id: 'varchar', hasCharMaxLength: true, usageLevel: 1 },
{
name: 'varchar',
id: 'varchar',
fieldAttributes: { hasCharMaxLength: true },
},
{ name: 'text', id: 'text', usageLevel: 1 },
// Level 2 - Second most common types
@@ -20,16 +40,39 @@ export const mariadbDataTypes: readonly DataTypeData[] = [
{ name: 'tinyint', id: 'tinyint' },
{ name: 'smallint', id: 'smallint' },
{ name: 'mediumint', id: 'mediumint' },
{ name: 'numeric', id: 'numeric' },
{
name: 'numeric',
id: 'numeric',
fieldAttributes: {
precision: {
max: 65,
min: 1,
default: 10,
},
scale: {
max: 30,
min: 0,
default: 0,
},
},
},
{ name: 'float', id: 'float' },
{ name: 'double', id: 'double' },
{ name: 'bit', id: 'bit' },
{ name: 'bool', id: 'bool' },
{ name: 'time', id: 'time' },
{ name: 'year', id: 'year' },
{ name: 'char', id: 'char', hasCharMaxLength: true },
{ name: 'binary', id: 'binary', hasCharMaxLength: true },
{ name: 'varbinary', id: 'varbinary', hasCharMaxLength: true },
{ name: 'char', id: 'char', fieldAttributes: { hasCharMaxLength: true } },
{
name: 'binary',
id: 'binary',
fieldAttributes: { hasCharMaxLength: true },
},
{
name: 'varbinary',
id: 'varbinary',
fieldAttributes: { hasCharMaxLength: true },
},
{ name: 'tinyblob', id: 'tinyblob' },
{ name: 'blob', id: 'blob' },
{ name: 'mediumblob', id: 'mediumblob' },

View File

@@ -3,7 +3,12 @@ import type { DataTypeData } from './data-types';
export const mysqlDataTypes: readonly DataTypeData[] = [
// Level 1 - Most commonly used types
{ name: 'int', id: 'int', usageLevel: 1 },
{ name: 'varchar', id: 'varchar', hasCharMaxLength: true, usageLevel: 1 },
{
name: 'varchar',
id: 'varchar',
fieldAttributes: { hasCharMaxLength: true },
usageLevel: 1,
},
{ name: 'text', id: 'text', usageLevel: 1 },
{ name: 'boolean', id: 'boolean', usageLevel: 1 },
{ name: 'timestamp', id: 'timestamp', usageLevel: 1 },
@@ -11,7 +16,23 @@ export const mysqlDataTypes: readonly DataTypeData[] = [
// Level 2 - Second most common types
{ name: 'bigint', id: 'bigint', usageLevel: 2 },
{ name: 'decimal', id: 'decimal', usageLevel: 2 },
{
name: 'decimal',
id: 'decimal',
usageLevel: 2,
fieldAttributes: {
precision: {
max: 65,
min: 1,
default: 10,
},
scale: {
max: 30,
min: 0,
default: 0,
},
},
},
{ name: 'datetime', id: 'datetime', usageLevel: 2 },
{ name: 'json', id: 'json', usageLevel: 2 },
@@ -22,7 +43,7 @@ export const mysqlDataTypes: readonly DataTypeData[] = [
{ name: 'float', id: 'float' },
{ name: 'double', id: 'double' },
{ name: 'bit', id: 'bit' },
{ name: 'char', id: 'char', hasCharMaxLength: true },
{ name: 'char', id: 'char', fieldAttributes: { hasCharMaxLength: true } },
{ name: 'tinytext', id: 'tinytext' },
{ name: 'mediumtext', id: 'mediumtext' },
{ name: 'longtext', id: 'longtext' },

View File

@@ -2,15 +2,30 @@ import type { DataTypeData } from './data-types';
export const oracleDataTypes: readonly DataTypeData[] = [
// Character types
{ name: 'VARCHAR2', id: 'varchar2', usageLevel: 1, hasCharMaxLength: true },
{
name: 'VARCHAR2',
id: 'varchar2',
usageLevel: 1,
fieldAttributes: { hasCharMaxLength: true },
},
{
name: 'NVARCHAR2',
id: 'nvarchar2',
usageLevel: 1,
hasCharMaxLength: true,
fieldAttributes: { hasCharMaxLength: true },
},
{
name: 'CHAR',
id: 'char',
usageLevel: 2,
fieldAttributes: { hasCharMaxLength: true },
},
{
name: 'NCHAR',
id: 'nchar',
usageLevel: 2,
fieldAttributes: { hasCharMaxLength: true },
},
{ name: 'CHAR', id: 'char', usageLevel: 2, hasCharMaxLength: true },
{ name: 'NCHAR', id: 'nchar', usageLevel: 2, hasCharMaxLength: true },
{ name: 'CLOB', id: 'clob', usageLevel: 2 },
{ name: 'NCLOB', id: 'nclob', usageLevel: 2 },
@@ -49,7 +64,12 @@ export const oracleDataTypes: readonly DataTypeData[] = [
{ name: 'BFILE', id: 'bfile', usageLevel: 2 },
// Other types
{ name: 'RAW', id: 'raw', usageLevel: 2, hasCharMaxLength: true },
{
name: 'RAW',
id: 'raw',
usageLevel: 2,
fieldAttributes: { hasCharMaxLength: true },
},
{ name: 'LONG RAW', id: 'long_raw', usageLevel: 2 },
{ name: 'ROWID', id: 'rowid', usageLevel: 2 },
{ name: 'UROWID', id: 'urowid', usageLevel: 2 },

View File

@@ -3,7 +3,12 @@ import type { DataTypeData } from './data-types';
export const postgresDataTypes: readonly DataTypeData[] = [
// Level 1 - Most commonly used types
{ name: 'integer', id: 'integer', usageLevel: 1 },
{ name: 'varchar', id: 'varchar', hasCharMaxLength: true, usageLevel: 1 },
{
name: 'varchar',
id: 'varchar',
fieldAttributes: { hasCharMaxLength: true },
usageLevel: 1,
},
{ name: 'text', id: 'text', usageLevel: 1 },
{ name: 'boolean', id: 'boolean', usageLevel: 1 },
{ name: 'timestamp', id: 'timestamp', usageLevel: 1 },
@@ -11,7 +16,23 @@ export const postgresDataTypes: readonly DataTypeData[] = [
// Level 2 - Second most common types
{ name: 'bigint', id: 'bigint', usageLevel: 2 },
{ name: 'decimal', id: 'decimal', usageLevel: 2 },
{
name: 'decimal',
id: 'decimal',
usageLevel: 2,
fieldAttributes: {
precision: {
max: 131072,
min: 0,
default: 10,
},
scale: {
max: 16383,
min: 0,
default: 2,
},
},
},
{ name: 'serial', id: 'serial', usageLevel: 2 },
{ name: 'json', id: 'json', usageLevel: 2 },
{ name: 'jsonb', id: 'jsonb', usageLevel: 2 },
@@ -23,18 +44,33 @@ export const postgresDataTypes: readonly DataTypeData[] = [
},
// Less common types
{ name: 'numeric', id: 'numeric' },
{
name: 'numeric',
id: 'numeric',
fieldAttributes: {
precision: {
max: 131072,
min: 0,
default: 10,
},
scale: {
max: 16383,
min: 0,
default: 2,
},
},
},
{ name: 'real', id: 'real' },
{ name: 'double precision', id: 'double_precision' },
{ name: 'smallserial', id: 'smallserial' },
{ name: 'bigserial', id: 'bigserial' },
{ name: 'money', id: 'money' },
{ name: 'smallint', id: 'smallint' },
{ name: 'char', id: 'char', hasCharMaxLength: true },
{ name: 'char', id: 'char', fieldAttributes: { hasCharMaxLength: true } },
{
name: 'character varying',
id: 'character_varying',
hasCharMaxLength: true,
fieldAttributes: { hasCharMaxLength: true },
},
{ name: 'time', id: 'time' },
{ name: 'timestamp without time zone', id: 'timestamp_without_time_zone' },

View File

@@ -4,32 +4,93 @@ export const sqlServerDataTypes: readonly DataTypeData[] = [
// Level 1 - Most commonly used types
{ name: 'int', id: 'int', usageLevel: 1 },
{ name: 'bit', id: 'bit', usageLevel: 1 },
{ name: 'varchar', id: 'varchar', hasCharMaxLength: true, usageLevel: 1 },
{ name: 'nvarchar', id: 'nvarchar', hasCharMaxLength: true, usageLevel: 1 },
{
name: 'varchar',
id: 'varchar',
fieldAttributes: {
hasCharMaxLength: true,
hasCharMaxLengthOption: true,
maxLength: 8000,
},
usageLevel: 1,
},
{
name: 'nvarchar',
id: 'nvarchar',
fieldAttributes: {
hasCharMaxLength: true,
hasCharMaxLengthOption: true,
maxLength: 4000,
},
usageLevel: 1,
},
{ name: 'text', id: 'text', usageLevel: 1 },
{ name: 'datetime', id: 'datetime', usageLevel: 1 },
{ name: 'date', id: 'date', usageLevel: 1 },
// Level 2 - Second most common types
{ name: 'bigint', id: 'bigint', usageLevel: 2 },
{ name: 'decimal', id: 'decimal', usageLevel: 2 },
{
name: 'decimal',
id: 'decimal',
usageLevel: 2,
fieldAttributes: {
precision: {
max: 38,
min: 1,
default: 18,
},
scale: {
max: 38,
min: 0,
default: 0,
},
},
},
{ name: 'datetime2', id: 'datetime2', usageLevel: 2 },
{ name: 'uniqueidentifier', id: 'uniqueidentifier', usageLevel: 2 },
{ name: 'json', id: 'json', usageLevel: 2 },
// Less common types
{ name: 'numeric', id: 'numeric' },
{
name: 'numeric',
id: 'numeric',
fieldAttributes: {
precision: {
max: 38,
min: 1,
default: 18,
},
scale: {
max: 38,
min: 0,
default: 0,
},
},
},
{ name: 'smallint', id: 'smallint' },
{ name: 'smallmoney', id: 'smallmoney' },
{ name: 'tinyint', id: 'tinyint' },
{ name: 'money', id: 'money' },
{ name: 'float', id: 'float' },
{ name: 'real', id: 'real' },
{ name: 'char', id: 'char', hasCharMaxLength: true },
{ name: 'nchar', id: 'nchar', hasCharMaxLength: true },
{ name: 'char', id: 'char', fieldAttributes: { hasCharMaxLength: true } },
{ name: 'nchar', id: 'nchar', fieldAttributes: { hasCharMaxLength: true } },
{ name: 'ntext', id: 'ntext' },
{ name: 'binary', id: 'binary', hasCharMaxLength: true },
{ name: 'varbinary', id: 'varbinary', hasCharMaxLength: true },
{
name: 'binary',
id: 'binary',
fieldAttributes: { hasCharMaxLength: true },
},
{
name: 'varbinary',
id: 'varbinary',
fieldAttributes: {
hasCharMaxLength: true,
hasCharMaxLengthOption: true,
maxLength: 8000,
},
},
{ name: 'image', id: 'image' },
{ name: 'datetimeoffset', id: 'datetimeoffset' },
{ name: 'smalldatetime', id: 'smalldatetime' },

View File

@@ -10,21 +10,41 @@ export const sqliteDataTypes: readonly DataTypeData[] = [
// SQLite type aliases and common types
{ name: 'int', id: 'int', usageLevel: 1 },
{ name: 'varchar', id: 'varchar', hasCharMaxLength: true, usageLevel: 1 },
{ name: 'timestamp', id: 'timestamp', usageLevel: 1 },
{ name: 'date', id: 'date', usageLevel: 1 },
{ name: 'datetime', id: 'datetime', usageLevel: 1 },
{ name: 'boolean', id: 'boolean', usageLevel: 1 },
{
name: 'varchar',
id: 'varchar',
fieldAttributes: {
hasCharMaxLength: true,
},
usageLevel: 1,
},
{
name: 'timestamp',
id: 'timestamp',
usageLevel: 1,
},
// Level 2 - Second most common types
{ name: 'numeric', id: 'numeric', usageLevel: 2 },
{ name: 'decimal', id: 'decimal', usageLevel: 2 },
{ name: 'float', id: 'float', usageLevel: 2 },
{
name: 'decimal',
id: 'decimal',
usageLevel: 2,
},
{ name: 'double', id: 'double', usageLevel: 2 },
{ name: 'json', id: 'json', usageLevel: 2 },
// Less common types (all map to SQLite storage classes)
{ name: 'char', id: 'char', hasCharMaxLength: true },
{
name: 'char',
id: 'char',
fieldAttributes: {
hasCharMaxLength: true,
},
usageLevel: 2,
},
{ name: 'binary', id: 'binary' },
{ name: 'varbinary', id: 'varbinary' },
{ name: 'smallint', id: 'smallint' },

View File

@@ -4,4 +4,5 @@ export const defaultSchemas: { [key in DatabaseType]?: string } = {
[DatabaseType.POSTGRESQL]: 'public',
[DatabaseType.SQL_SERVER]: 'dbo',
[DatabaseType.CLICKHOUSE]: 'default',
[DatabaseType.COCKROACHDB]: 'public',
};

View File

@@ -0,0 +1,870 @@
import { describe, it, expect, vi } from 'vitest';
import { exportBaseSQL } from '../export-sql-script';
import { DatabaseType } from '@/lib/domain/database-type';
import type { Diagram } from '@/lib/domain/diagram';
import type { DBTable } from '@/lib/domain/db-table';
import type { DBField } from '@/lib/domain/db-field';
// Mock the dbml/core importer
vi.mock('@dbml/core', () => ({
importer: {
import: vi.fn((sql: string) => {
// Return a simplified DBML for testing
return sql;
}),
},
}));
describe('DBML Export - SQL Generation Tests', () => {
// Helper to generate test IDs and timestamps
let idCounter = 0;
const testId = () => `test-id-${++idCounter}`;
const testTime = Date.now();
// Helper to create a field with all required properties
const createField = (overrides: Partial<DBField>): DBField =>
({
id: testId(),
name: 'field',
type: { id: 'text', name: 'text' },
primaryKey: false,
nullable: true,
unique: false,
createdAt: testTime,
...overrides,
}) as DBField;
// Helper to create a table with all required properties
const createTable = (overrides: Partial<DBTable>): DBTable =>
({
id: testId(),
name: 'table',
fields: [],
indexes: [],
createdAt: testTime,
x: 0,
y: 0,
width: 200,
...overrides,
}) as DBTable;
// Helper to create a diagram with all required properties
const createDiagram = (overrides: Partial<Diagram>): Diagram =>
({
id: testId(),
name: 'diagram',
databaseType: DatabaseType.GENERIC,
tables: [],
relationships: [],
createdAt: testTime,
updatedAt: testTime,
...overrides,
}) as Diagram;
describe('Composite Primary Keys', () => {
it('should handle tables with composite primary keys correctly', () => {
const tableId = testId();
const field1Id = testId();
const field2Id = testId();
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Enchanted Library',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: tableId,
name: 'spell_components',
fields: [
createField({
id: field1Id,
name: 'spell_id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: field2Id,
name: 'component_id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'quantity',
type: { id: 'integer', name: 'integer' },
primaryKey: false,
nullable: false,
unique: false,
default: '1',
}),
],
indexes: [],
color: '#FFD700',
}),
],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should contain composite primary key syntax
expect(sql).toContain('PRIMARY KEY (spell_id, component_id)');
// Should NOT contain individual PRIMARY KEY constraints
expect(sql).not.toMatch(/spell_id\s+uuid\s+NOT NULL\s+PRIMARY KEY/);
expect(sql).not.toMatch(
/component_id\s+uuid\s+NOT NULL\s+PRIMARY KEY/
);
});
it('should handle single primary keys inline', () => {
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Wizard Academy',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: testId(),
name: 'wizards',
fields: [
createField({
id: testId(),
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'name',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
nullable: false,
unique: false,
}),
],
indexes: [],
color: '#9370DB',
}),
],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should contain inline PRIMARY KEY
expect(sql).toMatch(/id\s+uuid\s+NOT NULL\s+PRIMARY KEY/);
// Should NOT contain separate PRIMARY KEY constraint
expect(sql).not.toContain('PRIMARY KEY (id)');
});
});
describe('Default Value Handling', () => {
it('should skip invalid default values like "has default"', () => {
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Potion Shop',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: testId(),
name: 'potions',
fields: [
createField({
id: testId(),
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'is_active',
type: { id: 'boolean', name: 'boolean' },
primaryKey: false,
nullable: true,
unique: false,
default: 'has default',
}),
createField({
id: testId(),
name: 'stock_count',
type: { id: 'integer', name: 'integer' },
primaryKey: false,
nullable: false,
unique: false,
default: 'DEFAULT has default',
}),
],
indexes: [],
color: '#98FB98',
}),
],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should not contain invalid default values
expect(sql).not.toContain('DEFAULT has default');
expect(sql).not.toContain('DEFAULT DEFAULT has default');
// The fields should still be in the table
expect(sql).toContain('is_active boolean');
expect(sql).toContain('stock_count integer NOT NULL'); // integer gets simplified to int
});
it('should handle valid default values correctly', () => {
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Treasure Vault',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: testId(),
name: 'treasures',
fields: [
createField({
id: testId(),
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'gold_value',
type: { id: 'numeric', name: 'numeric' },
primaryKey: false,
nullable: false,
unique: false,
default: '100.50',
precision: 10,
scale: 2,
}),
createField({
id: testId(),
name: 'created_at',
type: { id: 'timestamp', name: 'timestamp' },
primaryKey: false,
nullable: true,
unique: false,
default: 'now()',
}),
createField({
id: testId(),
name: 'currency',
type: { id: 'char', name: 'char' },
characterMaximumLength: '3',
primaryKey: false,
nullable: false,
unique: false,
default: 'EUR',
}),
],
indexes: [],
color: '#FFD700',
}),
],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should contain valid defaults
expect(sql).toContain('DEFAULT 100.50');
expect(sql).toContain('DEFAULT now()');
expect(sql).toContain('DEFAULT EUR');
});
it('should handle NOW and similar default values', () => {
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Quest Log',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: testId(),
name: 'quests',
fields: [
createField({
id: testId(),
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'created_at',
type: { id: 'timestamp', name: 'timestamp' },
primaryKey: false,
nullable: true,
unique: false,
default: 'NOW',
}),
createField({
id: testId(),
name: 'updated_at',
type: { id: 'timestamp', name: 'timestamp' },
primaryKey: false,
nullable: true,
unique: false,
default: "('now')",
}),
],
indexes: [],
color: '#4169E1',
}),
],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should convert NOW to NOW() and ('now') to now()
expect(sql).toContain('created_at timestamp DEFAULT NOW');
expect(sql).toContain('updated_at timestamp DEFAULT now()');
});
});
describe('Character Type Handling', () => {
it('should handle char types with and without length correctly', () => {
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Dragon Registry',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: testId(),
name: 'dragons',
fields: [
createField({
id: testId(),
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'element_code',
type: { id: 'char', name: 'char' },
characterMaximumLength: '2',
primaryKey: false,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'status',
type: { id: 'char', name: 'char' },
primaryKey: false,
nullable: false,
unique: false,
}),
],
indexes: [],
color: '#FF6347',
}),
],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should handle char with explicit length
expect(sql).toContain('element_code char(2)');
// Should add default length for char without length
expect(sql).toContain('status char(1)');
});
it('should not have spaces between char and parentheses', () => {
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Rune Inscriptions',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: testId(),
name: 'runes',
fields: [
createField({
id: testId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'symbol',
type: { id: 'char', name: 'char' },
characterMaximumLength: '5',
primaryKey: false,
nullable: false,
unique: true,
}),
],
indexes: [],
color: '#8B4513',
}),
],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should not contain "char (" with space
expect(sql).not.toContain('char (');
expect(sql).toContain('char(5)');
});
});
describe('Complex Table Structures', () => {
it('should handle tables with no primary key', () => {
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Alchemy Log',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: testId(),
name: 'experiment_logs',
fields: [
createField({
id: testId(),
name: 'experiment_id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: false,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'alchemist_id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: false,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'result',
type: { id: 'text', name: 'text' },
primaryKey: false,
nullable: true,
unique: false,
}),
createField({
id: testId(),
name: 'logged_at',
type: { id: 'timestamp', name: 'timestamp' },
primaryKey: false,
nullable: false,
unique: false,
default: 'now()',
}),
],
indexes: [],
color: '#32CD32',
}),
],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should create a valid table without primary key
expect(sql).toContain('CREATE TABLE experiment_logs');
expect(sql).not.toContain('PRIMARY KEY');
});
it('should handle multiple tables with relationships', () => {
const guildTableId = testId();
const memberTableId = testId();
const guildIdFieldId = testId();
const memberGuildIdFieldId = testId();
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Adventurer Guild System',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: guildTableId,
name: 'guilds',
fields: [
createField({
id: guildIdFieldId,
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'name',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
nullable: false,
unique: true,
}),
createField({
id: testId(),
name: 'founded_year',
type: { id: 'integer', name: 'integer' },
primaryKey: false,
nullable: true,
unique: false,
}),
],
indexes: [],
x: 0,
y: 0,
color: '#4169E1',
}),
createTable({
id: memberTableId,
name: 'guild_members',
fields: [
createField({
id: testId(),
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: memberGuildIdFieldId,
name: 'guild_id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: false,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'member_name',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'rank',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
nullable: true,
unique: false,
default: "'Novice'",
}),
],
indexes: [],
x: 250,
y: 0,
color: '#FFD700',
}),
],
relationships: [
{
id: testId(),
name: 'fk_guild_members_guild',
sourceTableId: memberTableId,
targetTableId: guildTableId,
sourceFieldId: memberGuildIdFieldId,
targetFieldId: guildIdFieldId,
sourceCardinality: 'many',
targetCardinality: 'one',
createdAt: testTime,
},
],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should create both tables
expect(sql).toContain('CREATE TABLE guilds');
expect(sql).toContain('CREATE TABLE guild_members');
// Should create foreign key
expect(sql).toContain(
'ALTER TABLE guild_members ADD CONSTRAINT fk_guild_members_guild FOREIGN KEY (guild_id) REFERENCES guilds (id)'
);
});
});
describe('Schema Support', () => {
it('should handle tables with schemas correctly', () => {
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Multi-Realm Database',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: testId(),
name: 'portals',
schema: 'transportation',
fields: [
createField({
id: testId(),
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'destination',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
nullable: false,
unique: false,
}),
],
indexes: [],
color: '#9370DB',
}),
createTable({
id: testId(),
name: 'spells',
schema: 'magic',
fields: [
createField({
id: testId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'name',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
nullable: false,
unique: true,
}),
],
indexes: [],
x: 250,
y: 0,
color: '#FF1493',
}),
],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should create schemas
expect(sql).toContain('CREATE SCHEMA IF NOT EXISTS transportation');
expect(sql).toContain('CREATE SCHEMA IF NOT EXISTS magic');
// Should use schema-qualified table names
expect(sql).toContain('CREATE TABLE transportation.portals');
expect(sql).toContain('CREATE TABLE magic.spells');
});
});
describe('Edge Cases', () => {
it('should handle empty tables array', () => {
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Empty Realm',
databaseType: DatabaseType.POSTGRESQL,
tables: [],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
expect(sql).toBe('');
});
it('should handle tables with empty fields', () => {
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Void Space',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: testId(),
name: 'empty_table',
fields: [],
indexes: [],
color: '#000000',
}),
],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should still create table structure
expect(sql).toContain('CREATE TABLE empty_table');
expect(sql).toContain('(\n\n)');
});
it('should handle special characters in default values', () => {
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Mystic Scrolls',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: testId(),
name: 'scrolls',
fields: [
createField({
id: testId(),
name: 'id',
type: { id: 'uuid', name: 'uuid' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'inscription',
type: { id: 'text', name: 'text' },
primaryKey: false,
nullable: true,
unique: false,
default: "'Ancient\\'s Wisdom'",
}),
],
indexes: [],
color: '#8B4513',
}),
],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should preserve escaped quotes
expect(sql).toContain("DEFAULT 'Ancient\\'s Wisdom'");
});
it('should handle numeric precision and scale', () => {
const diagram: Diagram = createDiagram({
id: testId(),
name: 'Treasury',
databaseType: DatabaseType.POSTGRESQL,
tables: [
createTable({
id: testId(),
name: 'gold_reserves',
fields: [
createField({
id: testId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
nullable: false,
unique: false,
}),
createField({
id: testId(),
name: 'amount',
type: { id: 'numeric', name: 'numeric' },
primaryKey: false,
nullable: false,
unique: false,
precision: 15,
scale: 2,
}),
createField({
id: testId(),
name: 'interest_rate',
type: { id: 'numeric', name: 'numeric' },
primaryKey: false,
nullable: true,
unique: false,
precision: 5,
}),
],
indexes: [],
color: '#FFD700',
}),
],
relationships: [],
});
const sql = exportBaseSQL({
diagram,
targetDatabaseType: DatabaseType.POSTGRESQL,
isDBMLFlow: true,
});
// Should include precision and scale
expect(sql).toContain('amount numeric(15, 2)');
// Should include precision only when scale is not provided
expect(sql).toContain('interest_rate numeric(5)');
});
});
});

View File

@@ -48,6 +48,50 @@ export function exportFieldComment(comment: string): string {
.join('');
}
export function escapeSQLComment(comment: string): string {
if (!comment) {
return '';
}
// Escape single quotes by doubling them
let escaped = comment.replace(/'/g, "''");
// Replace newlines with spaces to prevent breaking SQL syntax
// Some databases support multi-line comments with specific syntax,
// but for maximum compatibility, we'll replace newlines with spaces
escaped = escaped.replace(/[\r\n]+/g, ' ');
// Trim any excessive whitespace
escaped = escaped.replace(/\s+/g, ' ').trim();
return escaped;
}
export function formatTableComment(comment: string): string {
if (!comment) {
return '';
}
// Split by newlines and add -- to each line
return (
comment
.split('\n')
.map((line) => `-- ${line}`)
.join('\n') + '\n'
);
}
export function formatMSSQLTableComment(comment: string): string {
if (!comment) {
return '';
}
// For MSSQL, we use multi-line comment syntax
// Escape */ to prevent breaking the comment block
const escaped = comment.replace(/\*\//g, '* /');
return `/**\n${escaped}\n*/\n`;
}
export function getInlineFK(table: DBTable, diagram: Diagram): string {
if (!diagram.relationships) {
return '';

View File

@@ -1,5 +1,6 @@
import {
exportFieldComment,
formatMSSQLTableComment,
isFunction,
isKeyword,
strHasQuotes,
@@ -72,7 +73,13 @@ function parseMSSQLDefault(field: DBField): string {
return `'${defaultValue}'`;
}
export function exportMSSQL(diagram: Diagram): string {
export function exportMSSQL({
diagram,
onlyRelationships = false,
}: {
diagram: Diagram;
onlyRelationships?: boolean;
}): string {
if (!diagram.tables || !diagram.relationships) {
return '';
}
@@ -82,166 +89,254 @@ export function exportMSSQL(diagram: Diagram): string {
// Create CREATE SCHEMA statements for all schemas
let sqlScript = '';
const schemas = new Set<string>();
tables.forEach((table) => {
if (table.schema) {
schemas.add(table.schema);
}
});
if (!onlyRelationships) {
const schemas = new Set<string>();
// Add schema creation statements
schemas.forEach((schema) => {
sqlScript += `IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '${schema}')\nBEGIN\n EXEC('CREATE SCHEMA [${schema}]');\nEND;\n\n`;
});
// Generate table creation SQL
sqlScript += tables
.map((table: DBTable) => {
// Skip views
if (table.isView) {
return '';
tables.forEach((table) => {
if (table.schema) {
schemas.add(table.schema);
}
});
const tableName = table.schema
? `[${table.schema}].[${table.name}]`
: `[${table.name}]`;
// Add schema creation statements
schemas.forEach((schema) => {
sqlScript += `IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '${schema}')\nBEGIN\n EXEC('CREATE SCHEMA [${schema}]');\nEND;\n`;
});
return `${
table.comments ? `/**\n${table.comments}\n*/\n` : ''
}CREATE TABLE ${tableName} (\n${table.fields
.map((field: DBField) => {
const fieldName = `[${field.name}]`;
const typeName = field.type.name;
// Generate table creation SQL
sqlScript += tables
.map((table: DBTable) => {
// Skip views
if (table.isView) {
return '';
}
// Handle SQL Server specific type formatting
let typeWithSize = typeName;
if (field.characterMaximumLength) {
if (
typeName.toLowerCase() === 'varchar' ||
typeName.toLowerCase() === 'nvarchar' ||
typeName.toLowerCase() === 'char' ||
typeName.toLowerCase() === 'nchar'
) {
typeWithSize = `${typeName}(${field.characterMaximumLength})`;
const tableName = table.schema
? `[${table.schema}].[${table.name}]`
: `[${table.name}]`;
return `${
table.comments
? formatMSSQLTableComment(table.comments)
: ''
}CREATE TABLE ${tableName} (\n${table.fields
.map((field: DBField) => {
const fieldName = `[${field.name}]`;
const typeName = field.type.name;
// Handle SQL Server specific type formatting
let typeWithSize = typeName;
if (field.characterMaximumLength) {
if (
typeName.toLowerCase() === 'varchar' ||
typeName.toLowerCase() === 'nvarchar' ||
typeName.toLowerCase() === 'char' ||
typeName.toLowerCase() === 'nchar'
) {
typeWithSize = `${typeName}(${field.characterMaximumLength})`;
}
}
} else if (field.precision && field.scale) {
if (
typeName.toLowerCase() === 'decimal' ||
typeName.toLowerCase() === 'numeric'
) {
typeWithSize = `${typeName}(${field.precision}, ${field.scale})`;
if (field.precision && field.scale) {
if (
typeName.toLowerCase() === 'decimal' ||
typeName.toLowerCase() === 'numeric'
) {
typeWithSize = `${typeName}(${field.precision}, ${field.scale})`;
}
} else if (field.precision) {
if (
typeName.toLowerCase() === 'decimal' ||
typeName.toLowerCase() === 'numeric'
) {
typeWithSize = `${typeName}(${field.precision})`;
}
}
} else if (field.precision) {
if (
typeName.toLowerCase() === 'decimal' ||
typeName.toLowerCase() === 'numeric'
) {
typeWithSize = `${typeName}(${field.precision})`;
}
}
const notNull = field.nullable ? '' : ' NOT NULL';
const notNull = field.nullable ? '' : ' NOT NULL';
// Check if identity column
const identity = field.default
?.toLowerCase()
.includes('identity')
? ' IDENTITY(1,1)'
: '';
const unique =
!field.primaryKey && field.unique ? ' UNIQUE' : '';
// Handle default value using SQL Server specific parser
const defaultValue =
field.default &&
!field.default.toLowerCase().includes('identity')
? ` DEFAULT ${parseMSSQLDefault(field)}`
// Check if identity column
const identity = field.default
?.toLowerCase()
.includes('identity')
? ' IDENTITY(1,1)'
: '';
// Do not add PRIMARY KEY as a column constraint - will add as table constraint
return `${exportFieldComment(field.comments ?? '')} ${fieldName} ${typeWithSize}${notNull}${identity}${unique}${defaultValue}`;
})
.join(',\n')}${
table.fields.filter((f) => f.primaryKey).length > 0
? `,\n PRIMARY KEY (${table.fields
.filter((f) => f.primaryKey)
.map((f) => `[${f.name}]`)
.join(', ')})`
: ''
}\n);\n\n${table.indexes
.map((index) => {
const indexName = table.schema
? `[${table.schema}_${index.name}]`
: `[${index.name}]`;
const indexFields = index.fieldIds
.map((fieldId) => {
const field = table.fields.find(
(f) => f.id === fieldId
);
return field ? `[${field.name}]` : '';
const unique =
!field.primaryKey && field.unique ? ' UNIQUE' : '';
// Handle default value using SQL Server specific parser
const defaultValue =
field.default &&
!field.default.toLowerCase().includes('identity')
? ` DEFAULT ${parseMSSQLDefault(field)}`
: '';
// Do not add PRIMARY KEY as a column constraint - will add as table constraint
return `${exportFieldComment(field.comments ?? '')} ${fieldName} ${typeWithSize}${notNull}${identity}${unique}${defaultValue}`;
})
.join(',\n')}${
table.fields.filter((f) => f.primaryKey).length > 0
? `,\n PRIMARY KEY (${table.fields
.filter((f) => f.primaryKey)
.map((f) => `[${f.name}]`)
.join(', ')})`
: ''
}\n);\n${(() => {
const validIndexes = table.indexes
.map((index) => {
const indexName = table.schema
? `[${table.schema}_${index.name}]`
: `[${index.name}]`;
const indexFields = index.fieldIds
.map((fieldId) => {
const field = table.fields.find(
(f) => f.id === fieldId
);
return field ? `[${field.name}]` : '';
})
.filter(Boolean);
// SQL Server has a limit of 32 columns in an index
if (indexFields.length > 32) {
const warningComment = `/* WARNING: This index originally had ${indexFields.length} columns. It has been truncated to 32 columns due to SQL Server's index column limit. */\n`;
console.warn(
`Warning: Index ${indexName} on table ${tableName} has ${indexFields.length} columns. SQL Server limits indexes to 32 columns. The index will be truncated.`
);
indexFields.length = 32;
return indexFields.length > 0
? `${warningComment}CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName}\nON ${tableName} (${indexFields.join(', ')});`
: '';
}
return indexFields.length > 0
? `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName}\nON ${tableName} (${indexFields.join(', ')});`
: '';
})
.filter(Boolean);
// SQL Server has a limit of 32 columns in an index
if (indexFields.length > 32) {
const warningComment = `/* WARNING: This index originally had ${indexFields.length} columns. It has been truncated to 32 columns due to SQL Server's index column limit. */\n`;
console.warn(
`Warning: Index ${indexName} on table ${tableName} has ${indexFields.length} columns. SQL Server limits indexes to 32 columns. The index will be truncated.`
);
indexFields.length = 32;
return indexFields.length > 0
? `${warningComment}CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName}\nON ${tableName} (${indexFields.join(', ')});\n\n`
: '';
}
return indexFields.length > 0
? `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName}\nON ${tableName} (${indexFields.join(', ')});\n\n`
return validIndexes.length > 0
? `\n-- Indexes\n${validIndexes.join('\n')}`
: '';
})
.join('')}`;
})
.filter(Boolean) // Remove empty strings (views)
.join('\n');
})()}\n`;
})
.filter(Boolean) // Remove empty strings (views)
.join('\n');
}
// Generate foreign keys
sqlScript += `\n${relationships
.map((r: DBRelationship) => {
const sourceTable = tables.find((t) => t.id === r.sourceTableId);
const targetTable = tables.find((t) => t.id === r.targetTableId);
if (relationships.length > 0) {
sqlScript += '\n-- Foreign key constraints\n';
if (
!sourceTable ||
!targetTable ||
sourceTable.isView ||
targetTable.isView
) {
return '';
}
// Process all relationships and create FK objects with schema info
const foreignKeys = relationships
.map((r: DBRelationship) => {
const sourceTable = tables.find(
(t) => t.id === r.sourceTableId
);
const targetTable = tables.find(
(t) => t.id === r.targetTableId
);
const sourceField = sourceTable.fields.find(
(f) => f.id === r.sourceFieldId
);
const targetField = targetTable.fields.find(
(f) => f.id === r.targetFieldId
);
if (
!sourceTable ||
!targetTable ||
sourceTable.isView ||
targetTable.isView
) {
return '';
}
if (!sourceField || !targetField) {
return '';
}
const sourceField = sourceTable.fields.find(
(f) => f.id === r.sourceFieldId
);
const targetField = targetTable.fields.find(
(f) => f.id === r.targetFieldId
);
const sourceTableName = sourceTable.schema
? `[${sourceTable.schema}].[${sourceTable.name}]`
: `[${sourceTable.name}]`;
const targetTableName = targetTable.schema
? `[${targetTable.schema}].[${targetTable.name}]`
: `[${targetTable.name}]`;
if (!sourceField || !targetField) {
return '';
}
return `ALTER TABLE ${sourceTableName}\nADD CONSTRAINT [${r.name}] FOREIGN KEY([${sourceField.name}]) REFERENCES ${targetTableName}([${targetField.name}]);\n`;
})
.filter(Boolean) // Remove empty strings
.join('\n')}`;
// Determine which table should have the foreign key based on cardinality
let fkTable, fkField, refTable, refField;
if (
r.sourceCardinality === 'one' &&
r.targetCardinality === 'many'
) {
// FK goes on target table
fkTable = targetTable;
fkField = targetField;
refTable = sourceTable;
refField = sourceField;
} else if (
r.sourceCardinality === 'many' &&
r.targetCardinality === 'one'
) {
// FK goes on source table
fkTable = sourceTable;
fkField = sourceField;
refTable = targetTable;
refField = targetField;
} else if (
r.sourceCardinality === 'one' &&
r.targetCardinality === 'one'
) {
// For 1:1, FK can go on either side, but typically goes on the table that references the other
// We'll keep the current behavior for 1:1
fkTable = sourceTable;
fkField = sourceField;
refTable = targetTable;
refField = targetField;
} else {
// Many-to-many relationships need a junction table, skip for now
return '';
}
const fkTableName = fkTable.schema
? `[${fkTable.schema}].[${fkTable.name}]`
: `[${fkTable.name}]`;
const refTableName = refTable.schema
? `[${refTable.schema}].[${refTable.name}]`
: `[${refTable.name}]`;
return {
schema: fkTable.schema || 'dbo',
sql: `ALTER TABLE ${fkTableName} ADD CONSTRAINT [${r.name}] FOREIGN KEY([${fkField.name}]) REFERENCES ${refTableName}([${refField.name}]);`,
};
})
.filter(Boolean); // Remove empty objects
// Group foreign keys by schema
const fksBySchema = foreignKeys.reduce(
(acc, fk) => {
if (!fk) return acc;
const schema = fk.schema;
if (!acc[schema]) {
acc[schema] = [];
}
acc[schema].push(fk.sql);
return acc;
},
{} as Record<string, string[]>
);
// Sort schemas and generate SQL with separators
const sortedSchemas = Object.keys(fksBySchema).sort();
const fkSql = sortedSchemas
.map((schema, index) => {
const schemaFks = fksBySchema[schema].join('\n');
if (index === 0) {
return `-- Schema: ${schema}\n${schemaFks}`;
} else {
return `\n-- Schema: ${schema}\n${schemaFks}`;
}
})
.join('\n');
sqlScript += fkSql;
}
return sqlScript;
}

View File

@@ -1,5 +1,7 @@
import {
exportFieldComment,
escapeSQLComment,
formatTableComment,
isFunction,
isKeyword,
strHasQuotes,
@@ -168,7 +170,13 @@ function mapMySQLType(typeName: string): string {
return typeName;
}
export function exportMySQL(diagram: Diagram): string {
export function exportMySQL({
diagram,
onlyRelationships = false,
}: {
diagram: Diagram;
onlyRelationships?: boolean;
}): string {
if (!diagram.tables || !diagram.relationships) {
return '';
}
@@ -177,224 +185,245 @@ export function exportMySQL(diagram: Diagram): string {
const relationships = diagram.relationships;
// Start SQL script
let sqlScript = '-- MySQL database export\n\n';
let sqlScript = '-- MySQL database export\n';
// MySQL doesn't really use transactions for DDL statements but we'll add it for consistency
sqlScript += 'START TRANSACTION;\n\n';
if (!onlyRelationships) {
// MySQL doesn't really use transactions for DDL statements but we'll add it for consistency
sqlScript += 'START TRANSACTION;\n';
// Create databases (schemas) if they don't exist
const schemas = new Set<string>();
tables.forEach((table) => {
if (table.schema) {
schemas.add(table.schema);
}
});
schemas.forEach((schema) => {
sqlScript += `CREATE DATABASE IF NOT EXISTS \`${schema}\`;\n`;
});
if (schemas.size > 0) {
sqlScript += '\n';
}
// Generate table creation SQL
sqlScript += tables
.map((table: DBTable) => {
// Skip views
if (table.isView) {
return '';
// Create databases (schemas) if they don't exist
const schemas = new Set<string>();
tables.forEach((table) => {
if (table.schema) {
schemas.add(table.schema);
}
});
// Use schema prefix if available
const tableName = table.schema
? `\`${table.schema}\`.\`${table.name}\``
: `\`${table.name}\``;
schemas.forEach((schema) => {
sqlScript += `CREATE DATABASE IF NOT EXISTS \`${schema}\`;\n`;
});
// Get primary key fields
const primaryKeyFields = table.fields.filter((f) => f.primaryKey);
if (schemas.size > 0) {
sqlScript += '\n';
}
return `${
table.comments ? `-- ${table.comments}\n` : ''
}CREATE TABLE IF NOT EXISTS ${tableName} (\n${table.fields
.map((field: DBField) => {
const fieldName = `\`${field.name}\``;
// Generate table creation SQL
sqlScript += tables
.map((table: DBTable) => {
// Skip views
if (table.isView) {
return '';
}
// Handle type name - map to MySQL compatible types
const typeName = mapMySQLType(field.type.name);
// Use schema prefix if available
const tableName = table.schema
? `\`${table.schema}\`.\`${table.name}\``
: `\`${table.name}\``;
// Handle MySQL specific type formatting
let typeWithSize = typeName;
if (field.characterMaximumLength) {
if (
typeName.toLowerCase() === 'varchar' ||
typeName.toLowerCase() === 'char' ||
typeName.toLowerCase() === 'varbinary'
) {
typeWithSize = `${typeName}(${field.characterMaximumLength})`;
// Get primary key fields
const primaryKeyFields = table.fields.filter(
(f) => f.primaryKey
);
return `${
table.comments ? formatTableComment(table.comments) : ''
}\nCREATE TABLE IF NOT EXISTS ${tableName} (\n${table.fields
.map((field: DBField) => {
const fieldName = `\`${field.name}\``;
// Handle type name - map to MySQL compatible types
const typeName = mapMySQLType(field.type.name);
// Handle MySQL specific type formatting
let typeWithSize = typeName;
if (field.characterMaximumLength) {
if (
typeName.toLowerCase() === 'varchar' ||
typeName.toLowerCase() === 'char' ||
typeName.toLowerCase() === 'varbinary'
) {
typeWithSize = `${typeName}(${field.characterMaximumLength})`;
}
}
} else if (field.precision && field.scale) {
if (
typeName.toLowerCase() === 'decimal' ||
typeName.toLowerCase() === 'numeric'
) {
typeWithSize = `${typeName}(${field.precision}, ${field.scale})`;
if (field.precision && field.scale) {
if (
typeName.toLowerCase() === 'decimal' ||
typeName.toLowerCase() === 'numeric'
) {
typeWithSize = `${typeName}(${field.precision}, ${field.scale})`;
}
} else if (field.precision) {
if (
typeName.toLowerCase() === 'decimal' ||
typeName.toLowerCase() === 'numeric'
) {
typeWithSize = `${typeName}(${field.precision})`;
}
}
} else if (field.precision) {
// Set a default size for VARCHAR columns if not specified
if (
typeName.toLowerCase() === 'decimal' ||
typeName.toLowerCase() === 'numeric'
typeName.toLowerCase() === 'varchar' &&
!field.characterMaximumLength
) {
typeWithSize = `${typeName}(${field.precision})`;
typeWithSize = `${typeName}(255)`;
}
}
// Set a default size for VARCHAR columns if not specified
if (
typeName.toLowerCase() === 'varchar' &&
!field.characterMaximumLength
) {
typeWithSize = `${typeName}(255)`;
}
const notNull = field.nullable ? '' : ' NOT NULL';
const notNull = field.nullable ? '' : ' NOT NULL';
// Handle auto_increment - MySQL uses AUTO_INCREMENT keyword
let autoIncrement = '';
if (
field.primaryKey &&
(field.default?.toLowerCase().includes('identity') ||
field.default
// Handle auto_increment - MySQL uses AUTO_INCREMENT keyword
let autoIncrement = '';
if (
field.primaryKey &&
(field.default
?.toLowerCase()
.includes('autoincrement') ||
field.default?.includes('nextval'))
) {
autoIncrement = ' AUTO_INCREMENT';
}
.includes('identity') ||
field.default
?.toLowerCase()
.includes('autoincrement') ||
field.default?.includes('nextval'))
) {
autoIncrement = ' AUTO_INCREMENT';
}
// Only add UNIQUE constraint if the field is not part of the primary key
const unique =
!field.primaryKey && field.unique ? ' UNIQUE' : '';
// Only add UNIQUE constraint if the field is not part of the primary key
const unique =
!field.primaryKey && field.unique ? ' UNIQUE' : '';
// Handle default value
const defaultValue =
field.default &&
!field.default.toLowerCase().includes('identity') &&
!field.default
.toLowerCase()
.includes('autoincrement') &&
!field.default.includes('nextval')
? ` DEFAULT ${parseMySQLDefault(field)}`
// Handle default value
const defaultValue =
field.default &&
!field.default.toLowerCase().includes('identity') &&
!field.default
.toLowerCase()
.includes('autoincrement') &&
!field.default.includes('nextval')
? ` DEFAULT ${parseMySQLDefault(field)}`
: '';
// MySQL supports inline comments
const comment = field.comments
? ` COMMENT '${escapeSQLComment(field.comments)}'`
: '';
// MySQL supports inline comments
const comment = field.comments
? ` COMMENT '${field.comments.replace(/'/g, "''")}'`
: '';
return `${exportFieldComment(field.comments ?? '')} ${fieldName} ${typeWithSize}${notNull}${autoIncrement}${unique}${defaultValue}${comment}`;
})
.join(',\n')}${
// Add PRIMARY KEY as table constraint
primaryKeyFields.length > 0
? `,\n PRIMARY KEY (${primaryKeyFields
.map((f) => `\`${f.name}\``)
.join(', ')})`
: ''
}\n)${
// MySQL supports table comments
table.comments
? ` COMMENT='${escapeSQLComment(table.comments)}'`
: ''
};\n${
// Add indexes - MySQL creates them separately from the table definition
(() => {
const validIndexes = table.indexes
.map((index) => {
// Get the list of fields for this index
const indexFields = index.fieldIds
.map((fieldId) => {
const field = table.fields.find(
(f) => f.id === fieldId
);
return field ? field : null;
})
.filter(Boolean);
return `${exportFieldComment(field.comments ?? '')} ${fieldName} ${typeWithSize}${notNull}${autoIncrement}${unique}${defaultValue}${comment}`;
})
.join(',\n')}${
// Add PRIMARY KEY as table constraint
primaryKeyFields.length > 0
? `,\n PRIMARY KEY (${primaryKeyFields
.map((f) => `\`${f.name}\``)
.join(', ')})`
: ''
}\n)${
// MySQL supports table comments
table.comments
? ` COMMENT='${table.comments.replace(/'/g, "''")}'`
: ''
};\n\n${
// Add indexes - MySQL creates them separately from the table definition
table.indexes
.map((index) => {
// Get the list of fields for this index
const indexFields = index.fieldIds
.map((fieldId) => {
const field = table.fields.find(
(f) => f.id === fieldId
// Skip if this index exactly matches the primary key fields
if (
primaryKeyFields.length ===
indexFields.length &&
primaryKeyFields.every((pk) =>
indexFields.some(
(field) =>
field && field.id === pk.id
)
)
) {
return '';
}
// Create a unique index name by combining table name, field names, and a unique/non-unique indicator
const fieldNamesForIndex = indexFields
.map((field) => field?.name || '')
.join('_');
const uniqueIndicator = index.unique
? '_unique'
: '';
const indexName = `\`idx_${table.name}_${fieldNamesForIndex}${uniqueIndicator}\``;
// Get the properly quoted field names
const indexFieldNames = indexFields
.map((field) =>
field ? `\`${field.name}\`` : ''
)
.filter(Boolean);
// Check for text/blob fields that need special handling
const hasTextOrBlob = indexFields.some(
(field) => {
const typeName =
field?.type.name.toLowerCase() ||
'';
return (
typeName === 'text' ||
typeName === 'mediumtext' ||
typeName === 'longtext' ||
typeName === 'blob'
);
}
);
return field ? field : null;
// If there are TEXT/BLOB fields, need to add prefix length
const indexFieldsWithPrefix = hasTextOrBlob
? indexFieldNames.map((name) => {
const field = indexFields.find(
(f) => `\`${f?.name}\`` === name
);
if (!field) return name;
const typeName =
field.type.name.toLowerCase();
if (
typeName === 'text' ||
typeName === 'mediumtext' ||
typeName === 'longtext' ||
typeName === 'blob'
) {
// Add a prefix length for TEXT/BLOB fields (required in MySQL)
return `${name}(255)`;
}
return name;
})
: indexFieldNames;
return indexFieldNames.length > 0
? `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName} ON ${tableName} (${indexFieldsWithPrefix.join(', ')});`
: '';
})
.filter(Boolean);
// Skip if this index exactly matches the primary key fields
if (
primaryKeyFields.length === indexFields.length &&
primaryKeyFields.every((pk) =>
indexFields.some(
(field) => field && field.id === pk.id
)
)
) {
return '';
}
// Create a unique index name by combining table name, field names, and a unique/non-unique indicator
const fieldNamesForIndex = indexFields
.map((field) => field?.name || '')
.join('_');
const uniqueIndicator = index.unique ? '_unique' : '';
const indexName = `\`idx_${table.name}_${fieldNamesForIndex}${uniqueIndicator}\``;
// Get the properly quoted field names
const indexFieldNames = indexFields
.map((field) => (field ? `\`${field.name}\`` : ''))
.filter(Boolean);
// Check for text/blob fields that need special handling
const hasTextOrBlob = indexFields.some((field) => {
const typeName =
field?.type.name.toLowerCase() || '';
return (
typeName === 'text' ||
typeName === 'mediumtext' ||
typeName === 'longtext' ||
typeName === 'blob'
);
});
// If there are TEXT/BLOB fields, need to add prefix length
const indexFieldsWithPrefix = hasTextOrBlob
? indexFieldNames.map((name) => {
const field = indexFields.find(
(f) => `\`${f?.name}\`` === name
);
if (!field) return name;
const typeName =
field.type.name.toLowerCase();
if (
typeName === 'text' ||
typeName === 'mediumtext' ||
typeName === 'longtext' ||
typeName === 'blob'
) {
// Add a prefix length for TEXT/BLOB fields (required in MySQL)
return `${name}(255)`;
}
return name;
})
: indexFieldNames;
return indexFieldNames.length > 0
? `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName}\nON ${tableName} (${indexFieldsWithPrefix.join(', ')});\n`
return validIndexes.length > 0
? `\n-- Indexes\n${validIndexes.join('\n')}`
: '';
})
.filter(Boolean)
.join('\n')
}`;
})
.filter(Boolean) // Remove empty strings (views)
.join('\n');
})()
}\n`;
})
.filter(Boolean) // Remove empty strings (views)
.join('\n');
}
// Generate foreign keys
if (relationships.length > 0) {
sqlScript += '\n-- Foreign key constraints\n\n';
sqlScript += '\n-- Foreign key constraints\n';
sqlScript += relationships
const foreignKeys = relationships
.map((r: DBRelationship) => {
const sourceTable = tables.find(
(t) => t.id === r.sourceTableId
@@ -423,25 +452,62 @@ export function exportMySQL(diagram: Diagram): string {
return '';
}
const sourceTableName = sourceTable.schema
? `\`${sourceTable.schema}\`.\`${sourceTable.name}\``
: `\`${sourceTable.name}\``;
const targetTableName = targetTable.schema
? `\`${targetTable.schema}\`.\`${targetTable.name}\``
: `\`${targetTable.name}\``;
// Determine which table should have the foreign key based on cardinality
let fkTable, fkField, refTable, refField;
if (
r.sourceCardinality === 'one' &&
r.targetCardinality === 'many'
) {
// FK goes on target table
fkTable = targetTable;
fkField = targetField;
refTable = sourceTable;
refField = sourceField;
} else if (
r.sourceCardinality === 'many' &&
r.targetCardinality === 'one'
) {
// FK goes on source table
fkTable = sourceTable;
fkField = sourceField;
refTable = targetTable;
refField = targetField;
} else if (
r.sourceCardinality === 'one' &&
r.targetCardinality === 'one'
) {
// For 1:1, FK can go on either side, but typically goes on the table that references the other
// We'll keep the current behavior for 1:1
fkTable = sourceTable;
fkField = sourceField;
refTable = targetTable;
refField = targetField;
} else {
// Many-to-many relationships need a junction table, skip for now
return '';
}
const fkTableName = fkTable.schema
? `\`${fkTable.schema}\`.\`${fkTable.name}\``
: `\`${fkTable.name}\``;
const refTableName = refTable.schema
? `\`${refTable.schema}\`.\`${refTable.name}\``
: `\`${refTable.name}\``;
// Create a descriptive constraint name
const constraintName = `\`fk_${sourceTable.name}_${sourceField.name}\``;
const constraintName = `\`fk_${fkTable.name}_${fkField.name}\``;
// MySQL supports ON DELETE and ON UPDATE actions
return `ALTER TABLE ${sourceTableName}\nADD CONSTRAINT ${constraintName} FOREIGN KEY(\`${sourceField.name}\`) REFERENCES ${targetTableName}(\`${targetField.name}\`)\nON UPDATE CASCADE ON DELETE RESTRICT;\n`;
return `ALTER TABLE ${fkTableName} ADD CONSTRAINT ${constraintName} FOREIGN KEY(\`${fkField.name}\`) REFERENCES ${refTableName}(\`${refField.name}\`);`;
})
.filter(Boolean) // Remove empty strings
.join('\n');
.filter(Boolean); // Remove empty strings
sqlScript += foreignKeys.join('\n');
}
// Commit transaction
sqlScript += '\nCOMMIT;\n';
sqlScript += '\n\nCOMMIT;\n';
return sqlScript;
}

View File

@@ -1,5 +1,7 @@
import {
exportFieldComment,
escapeSQLComment,
formatTableComment,
isFunction,
isKeyword,
strHasQuotes,
@@ -140,10 +142,16 @@ function exportCustomTypes(customTypes: DBCustomType[]): string {
}
});
return typesSql + '\n';
return typesSql ? typesSql + '\n' : '';
}
export function exportPostgreSQL(diagram: Diagram): string {
export function exportPostgreSQL({
diagram,
onlyRelationships = false,
}: {
diagram: Diagram;
onlyRelationships?: boolean;
}): string {
if (!diagram.tables || !diagram.relationships) {
return '';
}
@@ -154,290 +162,391 @@ export function exportPostgreSQL(diagram: Diagram): string {
// Create CREATE SCHEMA statements for all schemas
let sqlScript = '';
const schemas = new Set<string>();
if (!onlyRelationships) {
const schemas = new Set<string>();
tables.forEach((table) => {
if (table.schema) {
schemas.add(table.schema);
}
});
// Also collect schemas from custom types
customTypes.forEach((customType) => {
if (customType.schema) {
schemas.add(customType.schema);
}
});
// Add schema creation statements
schemas.forEach((schema) => {
sqlScript += `CREATE SCHEMA IF NOT EXISTS "${schema}";\n`;
});
sqlScript += '\n';
// Add custom types (enums and composite types)
sqlScript += exportCustomTypes(customTypes);
// Add sequence creation statements
const sequences = new Set<string>();
tables.forEach((table) => {
table.fields.forEach((field) => {
if (field.default) {
// Match nextval('schema.sequence_name') or nextval('sequence_name')
const match = field.default.match(
/nextval\('([^']+)'(?:::[^)]+)?\)/
);
if (match) {
sequences.add(match[1]);
}
tables.forEach((table) => {
if (table.schema) {
schemas.add(table.schema);
}
});
});
sequences.forEach((sequence) => {
sqlScript += `CREATE SEQUENCE IF NOT EXISTS ${sequence};\n`;
});
sqlScript += '\n';
// Generate table creation SQL
sqlScript += tables
.map((table: DBTable) => {
// Skip views
if (table.isView) {
return '';
// Also collect schemas from custom types
customTypes.forEach((customType) => {
if (customType.schema) {
schemas.add(customType.schema);
}
});
const tableName = table.schema
? `"${table.schema}"."${table.name}"`
: `"${table.name}"`;
// Add schema creation statements
schemas.forEach((schema) => {
sqlScript += `CREATE SCHEMA IF NOT EXISTS "${schema}";\n`;
});
if (schemas.size > 0) {
sqlScript += '\n';
}
// Get primary key fields
const primaryKeyFields = table.fields.filter((f) => f.primaryKey);
// Add custom types (enums and composite types)
sqlScript += exportCustomTypes(customTypes);
return `${
table.comments ? `-- ${table.comments}\n` : ''
}CREATE TABLE ${tableName} (\n${table.fields
.map((field: DBField) => {
const fieldName = `"${field.name}"`;
// Add sequence creation statements
const sequences = new Set<string>();
// Handle type name - map problematic types to PostgreSQL compatible types
const typeName = mapPostgresType(
field.type.name,
field.name
tables.forEach((table) => {
table.fields.forEach((field) => {
if (field.default) {
// Match nextval('schema.sequence_name') or nextval('sequence_name')
const match = field.default.match(
/nextval\('([^']+)'(?:::[^)]+)?\)/
);
// Handle PostgreSQL specific type formatting
let typeWithSize = typeName;
let serialType = null;
if (field.increment && !field.nullable) {
if (
typeName.toLowerCase() === 'integer' ||
typeName.toLowerCase() === 'int'
) {
serialType = 'SERIAL';
} else if (typeName.toLowerCase() === 'bigint') {
serialType = 'BIGSERIAL';
} else if (typeName.toLowerCase() === 'smallint') {
serialType = 'SMALLSERIAL';
}
if (match) {
sequences.add(match[1]);
}
}
});
});
if (field.characterMaximumLength) {
if (
typeName.toLowerCase() === 'varchar' ||
typeName.toLowerCase() === 'character varying' ||
typeName.toLowerCase() === 'char' ||
typeName.toLowerCase() === 'character'
) {
typeWithSize = `${typeName}(${field.characterMaximumLength})`;
sequences.forEach((sequence) => {
sqlScript += `CREATE SEQUENCE IF NOT EXISTS ${sequence};\n`;
});
if (sequences.size > 0) {
sqlScript += '\n';
}
// Generate table creation SQL
sqlScript += tables
.map((table: DBTable) => {
// Skip views
if (table.isView) {
return '';
}
const tableName = table.schema
? `"${table.schema}"."${table.name}"`
: `"${table.name}"`;
// Get primary key fields
const primaryKeyFields = table.fields.filter(
(f) => f.primaryKey
);
return `${
table.comments ? formatTableComment(table.comments) : ''
}CREATE TABLE ${tableName} (\n${table.fields
.map((field: DBField) => {
const fieldName = `"${field.name}"`;
// Handle type name - map problematic types to PostgreSQL compatible types
const typeName = mapPostgresType(
field.type.name,
field.name
);
// Handle PostgreSQL specific type formatting
let typeWithSize = typeName;
let serialType = null;
if (field.increment && !field.nullable) {
if (
typeName.toLowerCase() === 'integer' ||
typeName.toLowerCase() === 'int'
) {
serialType = 'SERIAL';
} else if (typeName.toLowerCase() === 'bigint') {
serialType = 'BIGSERIAL';
} else if (typeName.toLowerCase() === 'smallint') {
serialType = 'SMALLSERIAL';
}
}
} else if (field.precision && field.scale) {
if (
typeName.toLowerCase() === 'decimal' ||
typeName.toLowerCase() === 'numeric'
) {
typeWithSize = `${typeName}(${field.precision}, ${field.scale})`;
if (field.characterMaximumLength) {
if (
typeName.toLowerCase() === 'varchar' ||
typeName.toLowerCase() ===
'character varying' ||
typeName.toLowerCase() === 'char' ||
typeName.toLowerCase() === 'character'
) {
typeWithSize = `${typeName}(${field.characterMaximumLength})`;
}
}
} else if (field.precision) {
if (
typeName.toLowerCase() === 'decimal' ||
typeName.toLowerCase() === 'numeric'
) {
typeWithSize = `${typeName}(${field.precision})`;
if (field.precision && field.scale) {
if (
typeName.toLowerCase() === 'decimal' ||
typeName.toLowerCase() === 'numeric'
) {
typeWithSize = `${typeName}(${field.precision}, ${field.scale})`;
}
} else if (field.precision) {
if (
typeName.toLowerCase() === 'decimal' ||
typeName.toLowerCase() === 'numeric'
) {
typeWithSize = `${typeName}(${field.precision})`;
}
}
}
// Handle array types (check if the type name ends with '[]')
if (typeName.endsWith('[]')) {
typeWithSize = typeWithSize.replace('[]', '') + '[]';
}
// Handle array types (check if the type name ends with '[]')
if (typeName.endsWith('[]')) {
typeWithSize =
typeWithSize.replace('[]', '') + '[]';
}
const notNull = field.nullable ? '' : ' NOT NULL';
const notNull = field.nullable ? '' : ' NOT NULL';
// Handle identity generation
let identity = '';
if (field.default && field.default.includes('nextval')) {
// PostgreSQL already handles this with DEFAULT nextval()
} else if (
field.default &&
field.default.toLowerCase().includes('identity')
) {
identity = ' GENERATED BY DEFAULT AS IDENTITY';
}
// Handle identity generation
let identity = '';
if (
field.default &&
field.default.includes('nextval')
) {
// PostgreSQL already handles this with DEFAULT nextval()
} else if (
field.default &&
field.default.toLowerCase().includes('identity')
) {
identity = ' GENERATED BY DEFAULT AS IDENTITY';
}
// Only add UNIQUE constraint if the field is not part of the primary key
// This avoids redundant uniqueness constraints
const unique =
!field.primaryKey && field.unique ? ' UNIQUE' : '';
// Only add UNIQUE constraint if the field is not part of the primary key
// This avoids redundant uniqueness constraints
const unique =
!field.primaryKey && field.unique ? ' UNIQUE' : '';
// Handle default value using PostgreSQL specific parser
const defaultValue =
field.default &&
!field.default.toLowerCase().includes('identity')
? ` DEFAULT ${parsePostgresDefault(field)}`
: '';
// Handle default value using PostgreSQL specific parser
const defaultValue =
field.default &&
!field.default.toLowerCase().includes('identity')
? ` DEFAULT ${parsePostgresDefault(field)}`
: '';
// Do not add PRIMARY KEY as a column constraint - will add as table constraint
return `${exportFieldComment(field.comments ?? '')} ${fieldName} ${serialType || typeWithSize}${serialType ? '' : notNull}${identity}${unique}${defaultValue}`;
})
.join(',\n')}${
primaryKeyFields.length > 0
? `,\n PRIMARY KEY (${primaryKeyFields
.map((f) => `"${f.name}"`)
.join(', ')})`
: ''
}\n);\n\n${
// Add table comments
table.comments
? `COMMENT ON TABLE ${tableName} IS '${table.comments.replace(/'/g, "''")}';\n\n`
: ''
}${
// Add column comments
table.fields
.filter((f) => f.comments)
.map(
(f) =>
`COMMENT ON COLUMN ${tableName}."${f.name}" IS '${f.comments?.replace(/'/g, "''")}';\n`
)
.join('')
}\n${
// Add indexes only for non-primary key fields or composite indexes
// This avoids duplicate indexes on primary key columns
table.indexes
.map((index) => {
// Get the list of fields for this index
const indexFields = index.fieldIds
.map((fieldId) => {
const field = table.fields.find(
(f) => f.id === fieldId
// Do not add PRIMARY KEY as a column constraint - will add as table constraint
return `${exportFieldComment(field.comments ?? '')} ${fieldName} ${serialType || typeWithSize}${serialType ? '' : notNull}${identity}${unique}${defaultValue}`;
})
.join(',\n')}${
primaryKeyFields.length > 0
? `,\n PRIMARY KEY (${primaryKeyFields
.map((f) => `"${f.name}"`)
.join(', ')})`
: ''
}\n);${
// Add table comments
table.comments
? `\nCOMMENT ON TABLE ${tableName} IS '${escapeSQLComment(table.comments)}';`
: ''
}${
// Add column comments
table.fields
.filter((f) => f.comments)
.map(
(f) =>
`\nCOMMENT ON COLUMN ${tableName}."${f.name}" IS '${escapeSQLComment(f.comments || '')}';`
)
.join('')
}${
// Add indexes only for non-primary key fields or composite indexes
// This avoids duplicate indexes on primary key columns
(() => {
const validIndexes = table.indexes
.map((index) => {
// Get the list of fields for this index
const indexFields = index.fieldIds
.map((fieldId) => {
const field = table.fields.find(
(f) => f.id === fieldId
);
return field ? field : null;
})
.filter(Boolean);
// Skip if this index exactly matches the primary key fields
// This prevents creating redundant indexes
if (
primaryKeyFields.length ===
indexFields.length &&
primaryKeyFields.every((pk) =>
indexFields.some(
(field) =>
field && field.id === pk.id
)
)
) {
return '';
}
// Create unique index name using table name and index name
// This ensures index names are unique across the database
const safeTableName = table.name.replace(
/[^a-zA-Z0-9_]/g,
'_'
);
return field ? field : null;
const safeIndexName = index.name.replace(
/[^a-zA-Z0-9_]/g,
'_'
);
// Limit index name length to avoid PostgreSQL's 63-character identifier limit
let combinedName = `${safeTableName}_${safeIndexName}`;
if (combinedName.length > 60) {
// If too long, use just the index name or a truncated version
combinedName =
safeIndexName.length > 60
? safeIndexName.substring(0, 60)
: safeIndexName;
}
const indexName = `"${combinedName}"`;
// Get the properly quoted field names
const indexFieldNames = indexFields
.map((field) =>
field ? `"${field.name}"` : ''
)
.filter(Boolean);
return indexFieldNames.length > 0
? `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName} ON ${tableName} (${indexFieldNames.join(', ')});`
: '';
})
.filter(Boolean);
// Skip if this index exactly matches the primary key fields
// This prevents creating redundant indexes
if (
primaryKeyFields.length === indexFields.length &&
primaryKeyFields.every((pk) =>
indexFields.some(
(field) => field && field.id === pk.id
)
)
) {
return '';
}
// Create unique index name using table name and index name
// This ensures index names are unique across the database
const safeTableName = table.name.replace(
/[^a-zA-Z0-9_]/g,
'_'
);
const safeIndexName = index.name.replace(
/[^a-zA-Z0-9_]/g,
'_'
);
// Limit index name length to avoid PostgreSQL's 63-character identifier limit
let combinedName = `${safeTableName}_${safeIndexName}`;
if (combinedName.length > 60) {
// If too long, use just the index name or a truncated version
combinedName =
safeIndexName.length > 60
? safeIndexName.substring(0, 60)
: safeIndexName;
}
const indexName = `"${combinedName}"`;
// Get the properly quoted field names
const indexFieldNames = indexFields
.map((field) => (field ? `"${field.name}"` : ''))
.filter(Boolean);
return indexFieldNames.length > 0
? `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName}\nON ${tableName} (${indexFieldNames.join(', ')});\n\n`
return validIndexes.length > 0
? `\n-- Indexes\n${validIndexes.join('\n')}`
: '';
})
.filter(Boolean)
.join('')
}`;
})
.filter(Boolean) // Remove empty strings (views)
.join('\n');
})()
}\n`;
})
.filter(Boolean) // Remove empty strings (views)
.join('\n');
}
// Generate foreign keys
sqlScript += `\n${relationships
.map((r: DBRelationship) => {
const sourceTable = tables.find((t) => t.id === r.sourceTableId);
const targetTable = tables.find((t) => t.id === r.targetTableId);
if (relationships.length > 0) {
sqlScript += '\n-- Foreign key constraints\n';
if (
!sourceTable ||
!targetTable ||
sourceTable.isView ||
targetTable.isView
) {
return '';
}
// Process all relationships and create FK objects with schema info
const foreignKeys = relationships
.map((r: DBRelationship) => {
const sourceTable = tables.find(
(t) => t.id === r.sourceTableId
);
const targetTable = tables.find(
(t) => t.id === r.targetTableId
);
const sourceField = sourceTable.fields.find(
(f) => f.id === r.sourceFieldId
);
const targetField = targetTable.fields.find(
(f) => f.id === r.targetFieldId
);
if (
!sourceTable ||
!targetTable ||
sourceTable.isView ||
targetTable.isView
) {
return '';
}
if (!sourceField || !targetField) {
return '';
}
const sourceField = sourceTable.fields.find(
(f) => f.id === r.sourceFieldId
);
const targetField = targetTable.fields.find(
(f) => f.id === r.targetFieldId
);
const sourceTableName = sourceTable.schema
? `"${sourceTable.schema}"."${sourceTable.name}"`
: `"${sourceTable.name}"`;
const targetTableName = targetTable.schema
? `"${targetTable.schema}"."${targetTable.name}"`
: `"${targetTable.name}"`;
if (!sourceField || !targetField) {
return '';
}
// Create a unique constraint name by combining table and field names
// Ensure it stays within PostgreSQL's 63-character limit for identifiers
// and doesn't get truncated in a way that breaks SQL syntax
const baseName = `fk_${sourceTable.name}_${sourceField.name}_${targetTable.name}_${targetField.name}`;
// Limit to 60 chars (63 minus quotes) to ensure the whole identifier stays within limits
const safeConstraintName =
baseName.length > 60
? baseName.substring(0, 60).replace(/[^a-zA-Z0-9_]/g, '_')
: baseName.replace(/[^a-zA-Z0-9_]/g, '_');
// Determine which table should have the foreign key based on cardinality
let fkTable, fkField, refTable, refField;
const constraintName = `"${safeConstraintName}"`;
if (
r.sourceCardinality === 'one' &&
r.targetCardinality === 'many'
) {
// FK goes on target table
fkTable = targetTable;
fkField = targetField;
refTable = sourceTable;
refField = sourceField;
} else if (
r.sourceCardinality === 'many' &&
r.targetCardinality === 'one'
) {
// FK goes on source table
fkTable = sourceTable;
fkField = sourceField;
refTable = targetTable;
refField = targetField;
} else if (
r.sourceCardinality === 'one' &&
r.targetCardinality === 'one'
) {
// For 1:1, FK can go on either side, but typically goes on the table that references the other
// We'll keep the current behavior for 1:1
fkTable = sourceTable;
fkField = sourceField;
refTable = targetTable;
refField = targetField;
} else {
// Many-to-many relationships need a junction table, skip for now
return '';
}
return `ALTER TABLE ${sourceTableName}\nADD CONSTRAINT ${constraintName} FOREIGN KEY("${sourceField.name}") REFERENCES ${targetTableName}("${targetField.name}");\n`;
})
.filter(Boolean) // Remove empty strings
.join('\n')}`;
const fkTableName = fkTable.schema
? `"${fkTable.schema}"."${fkTable.name}"`
: `"${fkTable.name}"`;
const refTableName = refTable.schema
? `"${refTable.schema}"."${refTable.name}"`
: `"${refTable.name}"`;
// Create a unique constraint name by combining table and field names
// Ensure it stays within PostgreSQL's 63-character limit for identifiers
// and doesn't get truncated in a way that breaks SQL syntax
const baseName = `fk_${fkTable.name}_${fkField.name}_${refTable.name}_${refField.name}`;
// Limit to 60 chars (63 minus quotes) to ensure the whole identifier stays within limits
const safeConstraintName =
baseName.length > 60
? baseName
.substring(0, 60)
.replace(/[^a-zA-Z0-9_]/g, '_')
: baseName.replace(/[^a-zA-Z0-9_]/g, '_');
const constraintName = `"${safeConstraintName}"`;
return {
schema: fkTable.schema || 'public',
sql: `ALTER TABLE ${fkTableName} ADD CONSTRAINT ${constraintName} FOREIGN KEY("${fkField.name}") REFERENCES ${refTableName}("${refField.name}");`,
};
})
.filter(Boolean); // Remove empty objects
// Group foreign keys by schema
const fksBySchema = foreignKeys.reduce(
(acc, fk) => {
if (!fk) return acc;
const schema = fk.schema;
if (!acc[schema]) {
acc[schema] = [];
}
acc[schema].push(fk.sql);
return acc;
},
{} as Record<string, string[]>
);
// Sort schemas and generate SQL with separators
const sortedSchemas = Object.keys(fksBySchema).sort();
const fkSql = sortedSchemas
.map((schema, index) => {
const schemaFks = fksBySchema[schema].join('\n');
if (index === 0) {
return `-- Schema: ${schema}\n${schemaFks}`;
} else {
return `\n-- Schema: ${schema}\n${schemaFks}`;
}
})
.join('\n');
sqlScript += fkSql;
}
return sqlScript;
}

View File

@@ -1,5 +1,6 @@
import {
exportFieldComment,
formatTableComment,
isFunction,
isKeyword,
strHasQuotes,
@@ -139,7 +140,13 @@ function mapSQLiteType(typeName: string, isPrimaryKey: boolean): string {
return typeName;
}
export function exportSQLite(diagram: Diagram): string {
export function exportSQLite({
diagram,
onlyRelationships = false,
}: {
diagram: Diagram;
onlyRelationships?: boolean;
}): string {
if (!diagram.tables || !diagram.relationships) {
return '';
}
@@ -148,10 +155,10 @@ export function exportSQLite(diagram: Diagram): string {
const relationships = diagram.relationships;
// Start SQL script - SQLite doesn't use schemas, so we skip schema creation
let sqlScript = '-- SQLite database export\n\n';
let sqlScript = '-- SQLite database export\n';
// Begin transaction for faster import
sqlScript += 'BEGIN TRANSACTION;\n\n';
sqlScript += 'BEGIN TRANSACTION;\n';
// SQLite doesn't have sequences, so we skip sequence creation
@@ -165,151 +172,167 @@ export function exportSQLite(diagram: Diagram): string {
'sqlite_master',
];
// Generate table creation SQL
sqlScript += tables
.map((table: DBTable) => {
// Skip views
if (table.isView) {
return '';
}
if (!onlyRelationships) {
// Generate table creation SQL
sqlScript += tables
.map((table: DBTable) => {
// Skip views
if (table.isView) {
return '';
}
// Skip SQLite system tables
if (sqliteSystemTables.includes(table.name.toLowerCase())) {
return `-- Skipping SQLite system table: "${table.name}"\n`;
}
// Skip SQLite system tables
if (sqliteSystemTables.includes(table.name.toLowerCase())) {
return `-- Skipping SQLite system table: "${table.name}"\n`;
}
// SQLite doesn't use schema prefixes, so we use just the table name
// Include the schema in a comment if it exists
const schemaComment = table.schema
? `-- Original schema: ${table.schema}\n`
: '';
const tableName = `"${table.name}"`;
// SQLite doesn't use schema prefixes, so we use just the table name
// Include the schema in a comment if it exists
const schemaComment = table.schema
? `-- Original schema: ${table.schema}\n`
: '';
const tableName = `"${table.name}"`;
// Get primary key fields
const primaryKeyFields = table.fields.filter((f) => f.primaryKey);
// Get primary key fields
const primaryKeyFields = table.fields.filter(
(f) => f.primaryKey
);
// Check if this is a single-column INTEGER PRIMARY KEY (for AUTOINCREMENT)
const singleIntegerPrimaryKey =
primaryKeyFields.length === 1 &&
(primaryKeyFields[0].type.name.toLowerCase() === 'integer' ||
primaryKeyFields[0].type.name.toLowerCase() === 'int');
// Check if this is a single-column INTEGER PRIMARY KEY (for AUTOINCREMENT)
const singleIntegerPrimaryKey =
primaryKeyFields.length === 1 &&
(primaryKeyFields[0].type.name.toLowerCase() ===
'integer' ||
primaryKeyFields[0].type.name.toLowerCase() === 'int');
return `${schemaComment}${
table.comments ? `-- ${table.comments}\n` : ''
}CREATE TABLE IF NOT EXISTS ${tableName} (\n${table.fields
.map((field: DBField) => {
const fieldName = `"${field.name}"`;
return `${schemaComment}${
table.comments ? formatTableComment(table.comments) : ''
}CREATE TABLE IF NOT EXISTS ${tableName} (\n${table.fields
.map((field: DBField) => {
const fieldName = `"${field.name}"`;
// Handle type name - map to SQLite compatible types
const typeName = mapSQLiteType(
field.type.name,
field.primaryKey
);
// Handle type name - map to SQLite compatible types
const typeName = mapSQLiteType(
field.type.name,
field.primaryKey
);
// SQLite ignores length specifiers, so we don't add them
// We'll keep this simple without size info
const typeWithoutSize = typeName;
// SQLite ignores length specifiers, so we don't add them
// We'll keep this simple without size info
const typeWithoutSize = typeName;
const notNull = field.nullable ? '' : ' NOT NULL';
const notNull = field.nullable ? '' : ' NOT NULL';
// Handle autoincrement - only works with INTEGER PRIMARY KEY
let autoIncrement = '';
if (
field.primaryKey &&
singleIntegerPrimaryKey &&
(field.default?.toLowerCase().includes('identity') ||
field.default
// Handle autoincrement - only works with INTEGER PRIMARY KEY
let autoIncrement = '';
if (
field.primaryKey &&
singleIntegerPrimaryKey &&
(field.default
?.toLowerCase()
.includes('autoincrement') ||
field.default?.includes('nextval'))
) {
autoIncrement = ' AUTOINCREMENT';
}
// Only add UNIQUE constraint if the field is not part of the primary key
const unique =
!field.primaryKey && field.unique ? ' UNIQUE' : '';
// Handle default value - Special handling for datetime() function
let defaultValue = '';
if (
field.default &&
!field.default.toLowerCase().includes('identity') &&
!field.default
.toLowerCase()
.includes('autoincrement') &&
!field.default.includes('nextval')
) {
// Special handling for quoted functions like 'datetime(\'\'now\'\')' - remove extra quotes
if (field.default.includes("datetime(''now'')")) {
defaultValue = ' DEFAULT CURRENT_TIMESTAMP';
} else {
defaultValue = ` DEFAULT ${parseSQLiteDefault(field)}`;
.includes('identity') ||
field.default
?.toLowerCase()
.includes('autoincrement') ||
field.default?.includes('nextval'))
) {
autoIncrement = ' AUTOINCREMENT';
}
}
// Add PRIMARY KEY inline only for single INTEGER primary key
const primaryKey =
field.primaryKey && singleIntegerPrimaryKey
? ' PRIMARY KEY' + autoIncrement
: '';
// Only add UNIQUE constraint if the field is not part of the primary key
const unique =
!field.primaryKey && field.unique ? ' UNIQUE' : '';
return `${exportFieldComment(field.comments ?? '')} ${fieldName} ${typeWithoutSize}${primaryKey}${notNull}${unique}${defaultValue}`;
})
.join(',\n')}${
// Add PRIMARY KEY as table constraint for composite primary keys or non-INTEGER primary keys
primaryKeyFields.length > 0 && !singleIntegerPrimaryKey
? `,\n PRIMARY KEY (${primaryKeyFields
.map((f) => `"${f.name}"`)
.join(', ')})`
: ''
}\n);\n\n${
// Add indexes - SQLite doesn't support indexes in CREATE TABLE
table.indexes
.map((index) => {
// Skip indexes that exactly match the primary key
const indexFields = index.fieldIds
.map((fieldId) => {
const field = table.fields.find(
(f) => f.id === fieldId
);
return field ? field : null;
// Handle default value - Special handling for datetime() function
let defaultValue = '';
if (
field.default &&
!field.default.toLowerCase().includes('identity') &&
!field.default
.toLowerCase()
.includes('autoincrement') &&
!field.default.includes('nextval')
) {
// Special handling for quoted functions like 'datetime(\'\'now\'\')' - remove extra quotes
if (field.default.includes("datetime(''now'')")) {
defaultValue = ' DEFAULT CURRENT_TIMESTAMP';
} else {
defaultValue = ` DEFAULT ${parseSQLiteDefault(field)}`;
}
}
// Add PRIMARY KEY inline only for single INTEGER primary key
const primaryKey =
field.primaryKey && singleIntegerPrimaryKey
? ' PRIMARY KEY' + autoIncrement
: '';
return `${exportFieldComment(field.comments ?? '')} ${fieldName} ${typeWithoutSize}${primaryKey}${notNull}${unique}${defaultValue}`;
})
.join(',\n')}${
// Add PRIMARY KEY as table constraint for composite primary keys or non-INTEGER primary keys
primaryKeyFields.length > 0 && !singleIntegerPrimaryKey
? `,\n PRIMARY KEY (${primaryKeyFields
.map((f) => `"${f.name}"`)
.join(', ')})`
: ''
}\n);\n${
// Add indexes - SQLite doesn't support indexes in CREATE TABLE
(() => {
const validIndexes = table.indexes
.map((index) => {
// Skip indexes that exactly match the primary key
const indexFields = index.fieldIds
.map((fieldId) => {
const field = table.fields.find(
(f) => f.id === fieldId
);
return field ? field : null;
})
.filter(Boolean);
// Get the properly quoted field names
const indexFieldNames = indexFields
.map((field) =>
field ? `"${field.name}"` : ''
)
.filter(Boolean);
// Skip if this index exactly matches the primary key fields
if (
primaryKeyFields.length ===
indexFields.length &&
primaryKeyFields.every((pk) =>
indexFields.some(
(field) =>
field && field.id === pk.id
)
)
) {
return '';
}
// Create safe index name
const safeIndexName =
`${table.name}_${index.name}`
.replace(/[^a-zA-Z0-9_]/g, '_')
.substring(0, 60);
return indexFieldNames.length > 0
? `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS "${safeIndexName}"\nON ${tableName} (${indexFieldNames.join(', ')});`
: '';
})
.filter(Boolean);
// Get the properly quoted field names
const indexFieldNames = indexFields
.map((field) => (field ? `"${field.name}"` : ''))
.filter(Boolean);
// Skip if this index exactly matches the primary key fields
if (
primaryKeyFields.length === indexFields.length &&
primaryKeyFields.every((pk) =>
indexFields.some(
(field) => field && field.id === pk.id
)
)
) {
return '';
}
// Create safe index name
const safeIndexName = `${table.name}_${index.name}`
.replace(/[^a-zA-Z0-9_]/g, '_')
.substring(0, 60);
return indexFieldNames.length > 0
? `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS "${safeIndexName}"\nON ${tableName} (${indexFieldNames.join(', ')});\n`
return validIndexes.length > 0
? `\n-- Indexes\n${validIndexes.join('\n')}`
: '';
})
.filter(Boolean)
.join('\n')
}`;
})
.filter(Boolean) // Remove empty strings (views)
.join('\n');
})()
}\n`;
})
.filter(Boolean) // Remove empty strings (views)
.join('\n');
}
// Generate table constraints and triggers for foreign keys
// SQLite handles foreign keys differently - we'll add them with CREATE TABLE statements
// But we'll also provide individual ALTER TABLE statements as comments for reference
@@ -318,7 +341,7 @@ export function exportSQLite(diagram: Diagram): string {
sqlScript += '\n-- Foreign key constraints\n';
sqlScript +=
'-- Note: SQLite requires foreign_keys pragma to be enabled:\n';
sqlScript += '-- PRAGMA foreign_keys = ON;\n\n';
sqlScript += '-- PRAGMA foreign_keys = ON;\n';
relationships.forEach((r: DBRelationship) => {
const sourceTable = tables.find((t) => t.id === r.sourceTableId);
@@ -346,8 +369,44 @@ export function exportSQLite(diagram: Diagram): string {
return;
}
// Determine which table should have the foreign key based on cardinality
let fkTable, fkField, refTable, refField;
if (
r.sourceCardinality === 'one' &&
r.targetCardinality === 'many'
) {
// FK goes on target table
fkTable = targetTable;
fkField = targetField;
refTable = sourceTable;
refField = sourceField;
} else if (
r.sourceCardinality === 'many' &&
r.targetCardinality === 'one'
) {
// FK goes on source table
fkTable = sourceTable;
fkField = sourceField;
refTable = targetTable;
refField = targetField;
} else if (
r.sourceCardinality === 'one' &&
r.targetCardinality === 'one'
) {
// For 1:1, FK can go on either side, but typically goes on the table that references the other
// We'll keep the current behavior for 1:1
fkTable = sourceTable;
fkField = sourceField;
refTable = targetTable;
refField = targetField;
} else {
// Many-to-many relationships need a junction table, skip for now
return;
}
// Create commented out version of what would be ALTER TABLE statement
sqlScript += `-- ALTER TABLE "${sourceTable.name}" ADD CONSTRAINT "fk_${sourceTable.name}_${sourceField.name}" FOREIGN KEY("${sourceField.name}") REFERENCES "${targetTable.name}"("${targetField.name}");\n`;
sqlScript += `-- ALTER TABLE "${fkTable.name}" ADD CONSTRAINT "fk_${fkTable.name}_${fkField.name}" FOREIGN KEY("${fkField.name}") REFERENCES "${refTable.name}"("${refField.name}");\n`;
});
}

View File

@@ -11,23 +11,7 @@ import { exportMySQL } from './export-per-type/mysql';
// Function to simplify verbose data type names
const simplifyDataType = (typeName: string): string => {
const typeMap: Record<string, string> = {
'character varying': 'varchar',
'char varying': 'varchar',
integer: 'int',
int4: 'int',
int8: 'bigint',
serial4: 'serial',
serial8: 'bigserial',
float8: 'double precision',
float4: 'real',
bool: 'boolean',
character: 'char',
'timestamp without time zone': 'timestamp',
'timestamp with time zone': 'timestamptz',
'time without time zone': 'time',
'time with time zone': 'timetz',
};
const typeMap: Record<string, string> = {};
return typeMap[typeName.toLowerCase()] || typeName;
};
@@ -36,10 +20,12 @@ export const exportBaseSQL = ({
diagram,
targetDatabaseType,
isDBMLFlow = false,
onlyRelationships = false,
}: {
diagram: Diagram;
targetDatabaseType: DatabaseType;
isDBMLFlow?: boolean;
onlyRelationships?: boolean;
}): string => {
const { tables, relationships } = diagram;
@@ -50,16 +36,16 @@ export const exportBaseSQL = ({
if (!isDBMLFlow && diagram.databaseType === targetDatabaseType) {
switch (diagram.databaseType) {
case DatabaseType.SQL_SERVER:
return exportMSSQL(diagram);
return exportMSSQL({ diagram, onlyRelationships });
case DatabaseType.POSTGRESQL:
return exportPostgreSQL(diagram);
return exportPostgreSQL({ diagram, onlyRelationships });
case DatabaseType.SQLITE:
return exportSQLite(diagram);
return exportSQLite({ diagram, onlyRelationships });
case DatabaseType.MYSQL:
case DatabaseType.MARIADB:
return exportMySQL(diagram);
return exportMySQL({ diagram, onlyRelationships });
default:
return exportPostgreSQL(diagram);
return exportPostgreSQL({ diagram, onlyRelationships });
}
}
@@ -131,7 +117,23 @@ export const exportBaseSQL = ({
}
}
});
sqlScript += '\n'; // Add a newline if custom types were processed
if (
diagram.customTypes.some(
(ct) =>
(ct.kind === 'enum' &&
ct.values &&
ct.values.length > 0 &&
targetDatabaseType === DatabaseType.POSTGRESQL &&
!isDBMLFlow) ||
(ct.kind === 'composite' &&
ct.fields &&
ct.fields.length > 0 &&
(targetDatabaseType === DatabaseType.POSTGRESQL ||
isDBMLFlow))
)
) {
sqlScript += '\n';
}
}
// Add CREATE SEQUENCE statements
@@ -154,7 +156,9 @@ export const exportBaseSQL = ({
sequences.forEach((sequence) => {
sqlScript += `CREATE SEQUENCE IF NOT EXISTS ${sequence};\n`;
});
sqlScript += '\n';
if (sequences.size > 0) {
sqlScript += '\n';
}
// Loop through each non-view table to generate the SQL statements
nonViewTables.forEach((table) => {
@@ -163,6 +167,12 @@ export const exportBaseSQL = ({
: table.name;
sqlScript += `CREATE TABLE ${tableName} (\n`;
// Check for composite primary keys
const primaryKeyFields = table.fields.filter(
(field) => field.primaryKey
);
const hasCompositePrimaryKey = primaryKeyFields.length > 1;
table.fields.forEach((field, index) => {
let typeName = simplifyDataType(field.type.name);
@@ -214,14 +224,33 @@ export const exportBaseSQL = ({
typeName = 'text[]';
}
// Handle special types
if (
typeName.toLowerCase() === 'char' &&
!field.characterMaximumLength
) {
// Default char without length to char(1)
typeName = 'char';
}
sqlScript += ` ${field.name} ${typeName}`;
// Add size for character types
if (field.characterMaximumLength) {
if (
field.characterMaximumLength &&
parseInt(field.characterMaximumLength) > 0 &&
field.type.name.toLowerCase() !== 'decimal'
) {
sqlScript += `(${field.characterMaximumLength})`;
} else if (field.type.name.toLowerCase().includes('varchar')) {
// Keep varchar sizing, but don't apply to TEXT (previously enum)
sqlScript += `(500)`;
} else if (
typeName.toLowerCase() === 'char' &&
!field.characterMaximumLength
) {
// Default char without explicit length to char(1) for compatibility
sqlScript += `(1)`;
}
// Add precision and scale for numeric types
@@ -246,49 +275,63 @@ export const exportBaseSQL = ({
// Temp remove default user-define value when it have it
let fieldDefault = field.default;
// Remove the type cast part after :: if it exists
if (fieldDefault.includes('::')) {
const endedWithParentheses = fieldDefault.endsWith(')');
fieldDefault = fieldDefault.split('::')[0];
// Skip invalid default values for DBML export
if (
fieldDefault === 'has default' ||
fieldDefault === 'DEFAULT has default'
) {
// Skip this default value as it's invalid SQL
} else {
// Remove the type cast part after :: if it exists
if (fieldDefault.includes('::')) {
const endedWithParentheses = fieldDefault.endsWith(')');
fieldDefault = fieldDefault.split('::')[0];
if (
(fieldDefault.startsWith('(') &&
!fieldDefault.endsWith(')')) ||
endedWithParentheses
) {
fieldDefault += ')';
if (
(fieldDefault.startsWith('(') &&
!fieldDefault.endsWith(')')) ||
endedWithParentheses
) {
fieldDefault += ')';
}
}
}
if (fieldDefault === `('now')`) {
fieldDefault = `now()`;
}
if (fieldDefault === `('now')`) {
fieldDefault = `now()`;
}
sqlScript += ` DEFAULT ${fieldDefault}`;
sqlScript += ` DEFAULT ${fieldDefault}`;
}
}
// Handle PRIMARY KEY constraint
if (field.primaryKey) {
// Handle PRIMARY KEY constraint - only add inline if not composite
if (field.primaryKey && !hasCompositePrimaryKey) {
sqlScript += ' PRIMARY KEY';
}
// Add a comma after each field except the last one
if (index < table.fields.length - 1) {
// Add a comma after each field except the last one (or before composite primary key)
if (index < table.fields.length - 1 || hasCompositePrimaryKey) {
sqlScript += ',\n';
}
});
sqlScript += '\n);\n\n';
// Add composite primary key constraint if needed
if (hasCompositePrimaryKey) {
const pkFieldNames = primaryKeyFields.map((f) => f.name).join(', ');
sqlScript += `\n PRIMARY KEY (${pkFieldNames})`;
}
sqlScript += '\n);\n';
// Add table comment
if (table.comments) {
sqlScript += `COMMENT ON TABLE ${tableName} IS '${table.comments}';\n`;
sqlScript += `COMMENT ON TABLE ${tableName} IS '${table.comments.replace(/'/g, "''")}';\n`;
}
table.fields.forEach((field) => {
// Add column comment
if (field.comments) {
sqlScript += `COMMENT ON COLUMN ${tableName}.${field.name} IS '${field.comments}';\n`;
sqlScript += `COMMENT ON COLUMN ${tableName}.${field.name} IS '${field.comments.replace(/'/g, "''")}';\n`;
}
});
@@ -303,16 +346,19 @@ export const exportBaseSQL = ({
.join(', ');
if (fieldNames) {
const indexName = table.schema
? `${table.schema}_${index.name}`
: index.name;
const indexName =
table.schema && !isDBMLFlow
? `${table.schema}_${index.name}`
: index.name;
sqlScript += `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName} ON ${tableName} (${fieldNames});\n`;
}
});
sqlScript += '\n';
});
if (nonViewTables.length > 0 && (relationships?.length ?? 0) > 0) {
sqlScript += '\n';
}
// Handle relationships (foreign keys)
relationships?.forEach((relationship) => {
const sourceTable = nonViewTables.find(
@@ -335,13 +381,52 @@ export const exportBaseSQL = ({
sourceTableField &&
targetTableField
) {
const sourceTableName = sourceTable.schema
? `${sourceTable.schema}.${sourceTable.name}`
: sourceTable.name;
const targetTableName = targetTable.schema
? `${targetTable.schema}.${targetTable.name}`
: targetTable.name;
sqlScript += `ALTER TABLE ${sourceTableName} ADD CONSTRAINT ${relationship.name} FOREIGN KEY (${sourceTableField.name}) REFERENCES ${targetTableName} (${targetTableField.name});\n`;
// Determine which table should have the foreign key based on cardinality
// In a 1:many relationship, the foreign key goes on the "many" side
// If source is "one" and target is "many", FK goes on target table
// If source is "many" and target is "one", FK goes on source table
let fkTable, fkField, refTable, refField;
if (
relationship.sourceCardinality === 'one' &&
relationship.targetCardinality === 'many'
) {
// FK goes on target table
fkTable = targetTable;
fkField = targetTableField;
refTable = sourceTable;
refField = sourceTableField;
} else if (
relationship.sourceCardinality === 'many' &&
relationship.targetCardinality === 'one'
) {
// FK goes on source table
fkTable = sourceTable;
fkField = sourceTableField;
refTable = targetTable;
refField = targetTableField;
} else if (
relationship.sourceCardinality === 'one' &&
relationship.targetCardinality === 'one'
) {
// For 1:1, FK can go on either side, but typically goes on the table that references the other
// We'll keep the current behavior for 1:1
fkTable = sourceTable;
fkField = sourceTableField;
refTable = targetTable;
refField = targetTableField;
} else {
// Many-to-many relationships need a junction table, skip for now
return;
}
const fkTableName = fkTable.schema
? `${fkTable.schema}.${fkTable.name}`
: fkTable.name;
const refTableName = refTable.schema
? `${refTable.schema}.${refTable.name}`
: refTable.name;
sqlScript += `ALTER TABLE ${fkTableName} ADD CONSTRAINT ${relationship.name} FOREIGN KEY (${fkField.name}) REFERENCES ${refTableName} (${refField.name});\n`;
}
});

View File

@@ -0,0 +1,126 @@
import type { DatabaseMetadata } from './metadata-types/database-metadata';
import { schemaNameToDomainSchemaName } from '@/lib/domain/db-schema';
export interface SelectedTable {
schema?: string | null;
table: string;
type: 'table' | 'view';
}
export function filterMetadataByTables({
metadata,
selectedTables: inputSelectedTables,
}: {
metadata: DatabaseMetadata;
selectedTables: SelectedTable[];
}): DatabaseMetadata {
const selectedTables = inputSelectedTables.map((st) => {
// Normalize schema names to ensure consistent filtering
const schema = schemaNameToDomainSchemaName(st.schema) ?? '';
return {
...st,
schema,
};
});
// Create sets for faster lookup
const selectedTableSet = new Set(
selectedTables
.filter((st) => st.type === 'table')
.map((st) => `${st.schema}.${st.table}`)
);
const selectedViewSet = new Set(
selectedTables
.filter((st) => st.type === 'view')
.map((st) => `${st.schema}.${st.table}`)
);
// Filter tables
const filteredTables = metadata.tables.filter((table) => {
const schema = schemaNameToDomainSchemaName(table.schema) ?? '';
const tableId = `${schema}.${table.table}`;
return selectedTableSet.has(tableId);
});
// Filter views - include views that were explicitly selected
const filteredViews =
metadata.views?.filter((view) => {
const schema = schemaNameToDomainSchemaName(view.schema) ?? '';
const viewName = view.view_name ?? '';
const viewId = `${schema}.${viewName}`;
return selectedViewSet.has(viewId);
}) || [];
// Filter columns - include columns from both tables and views
const filteredColumns = metadata.columns.filter((col) => {
const fromTable = filteredTables.some(
(tb) => tb.schema === col.schema && tb.table === col.table
);
// For views, the column.table field might contain the view name
const fromView = filteredViews.some(
(view) => view.schema === col.schema && view.view_name === col.table
);
return fromTable || fromView;
});
// Filter primary keys
const filteredPrimaryKeys = metadata.pk_info.filter((pk) =>
filteredTables.some(
(tb) => tb.schema === pk.schema && tb.table === pk.table
)
);
// Filter indexes
const filteredIndexes = metadata.indexes.filter((idx) =>
filteredTables.some(
(tb) => tb.schema === idx.schema && tb.table === idx.table
)
);
// Filter foreign keys - include if either source or target table is selected
// This ensures all relationships related to selected tables are preserved
const filteredForeignKeys = metadata.fk_info.filter((fk) => {
// Handle reference_schema and reference_table fields from the JSON
const targetSchema = fk.reference_schema;
const targetTable = (fk.reference_table || '').replace(/^"+|"+$/g, ''); // Remove extra quotes
const sourceIncluded = filteredTables.some(
(tb) => tb.schema === fk.schema && tb.table === fk.table
);
const targetIncluded = filteredTables.some(
(tb) => tb.schema === targetSchema && tb.table === targetTable
);
return sourceIncluded || targetIncluded;
});
const schemasWithTables = new Set(filteredTables.map((tb) => tb.schema));
const schemasWithViews = new Set(filteredViews.map((view) => view.schema));
// Filter custom types if they exist
const filteredCustomTypes =
metadata.custom_types?.filter((customType) => {
// Also check if the type is used by any of the selected tables' columns
const typeUsedInColumns = filteredColumns.some(
(col) =>
col.type === customType.type ||
col.type.includes(customType.type) // Handle array types like "custom_type[]"
);
return (
schemasWithTables.has(customType.schema) ||
schemasWithViews.has(customType.schema) ||
typeUsedInColumns
);
}) || [];
return {
...metadata,
tables: filteredTables,
columns: filteredColumns,
pk_info: filteredPrimaryKeys,
indexes: filteredIndexes,
fk_info: filteredForeignKeys,
views: filteredViews,
custom_types: filteredCustomTypes,
};
}

View File

@@ -1,4 +1,3 @@
import { schemaNameToDomainSchemaName } from '@/lib/domain/db-schema';
import type { TableInfo } from './table-info';
import { z } from 'zod';
@@ -33,20 +32,12 @@ export type AggregatedIndexInfo = Omit<IndexInfo, 'column'> & {
};
export const createAggregatedIndexes = ({
tableInfo,
tableSchema,
indexes,
tableIndexes,
}: {
tableInfo: TableInfo;
indexes: IndexInfo[];
tableIndexes: IndexInfo[];
tableSchema?: string;
}): AggregatedIndexInfo[] => {
const tableIndexes = indexes.filter((idx) => {
const indexSchema = schemaNameToDomainSchemaName(idx.schema);
return idx.table === tableInfo.table && indexSchema === tableSchema;
});
return Object.values(
tableIndexes.reduce(
(acc, idx) => {

View File

@@ -2,140 +2,151 @@ const withExtras = false;
const withDefault = `IFNULL(REPLACE(REPLACE(cols.column_default, '\\\\', ''), '"', 'ֿֿֿ\\"'), '')`;
const withoutDefault = `""`;
export const mariaDBQuery = `WITH fk_info as (
(SELECT (@fk_info:=NULL),
(SELECT (0)
FROM (SELECT kcu.table_schema,
kcu.table_name,
kcu.column_name as fk_column,
kcu.constraint_name as foreign_key_name,
kcu.referenced_table_schema as reference_schema,
kcu.referenced_table_name as reference_table,
kcu.referenced_column_name as reference_column,
CONCAT('FOREIGN KEY (', kcu.column_name, ') REFERENCES ',
kcu.referenced_table_name, '(', kcu.referenced_column_name, ') ',
'ON UPDATE ', rc.update_rule,
' ON DELETE ', rc.delete_rule) AS fk_def
FROM
information_schema.key_column_usage kcu
JOIN
information_schema.referential_constraints rc
ON kcu.constraint_name = rc.constraint_name
AND kcu.table_schema = rc.constraint_schema
AND kcu.table_name = rc.table_name
WHERE
kcu.referenced_table_name IS NOT NULL) as fk
WHERE table_schema LIKE IFNULL(NULL, '%')
AND table_schema = DATABASE()
AND (0x00) IN (@fk_info:=CONCAT_WS(',', @fk_info, CONCAT('{"schema":"',table_schema,
'","table":"',table_name,
'","column":"', IFNULL(fk_column, ''),
'","foreign_key_name":"', IFNULL(foreign_key_name, ''),
'","reference_schema":"', IFNULL(reference_schema, ''),
'","reference_table":"', IFNULL(reference_table, ''),
'","reference_column":"', IFNULL(reference_column, ''),
'","fk_def":"', IFNULL(fk_def, ''),
'"}')))))
), pk_info AS (
(SELECT (@pk_info:=NULL),
(SELECT (0)
FROM (SELECT TABLE_SCHEMA,
TABLE_NAME AS pk_table,
COLUMN_NAME AS pk_column,
(SELECT CONCAT('PRIMARY KEY (', GROUP_CONCAT(inc.COLUMN_NAME ORDER BY inc.ORDINAL_POSITION SEPARATOR ', '), ')')
export const mariaDBQuery = `SET SESSION group_concat_max_len = 10000000;
SELECT CAST(CONCAT(
'{"fk_info": [',
IFNULL((SELECT GROUP_CONCAT(
CONCAT('{"schema":"', cast(fk.table_schema as CHAR),
'","table":"', fk.table_name,
'","column":"', IFNULL(fk.fk_column, ''),
'","foreign_key_name":"', IFNULL(fk.foreign_key_name, ''),
'","reference_table":"', IFNULL(fk.reference_table, ''),
'","reference_schema":"', IFNULL(fk.reference_schema, ''),
'","reference_column":"', IFNULL(fk.reference_column, ''),
'","fk_def":"', IFNULL(fk.fk_def, ''), '"}')
) FROM (
SELECT kcu.table_schema,
kcu.table_name,
kcu.column_name AS fk_column,
kcu.constraint_name AS foreign_key_name,
kcu.referenced_table_schema as reference_schema,
kcu.referenced_table_name AS reference_table,
kcu.referenced_column_name AS reference_column,
CONCAT('FOREIGN KEY (', kcu.column_name, ') REFERENCES ',
kcu.referenced_table_name, '(', kcu.referenced_column_name, ') ',
'ON UPDATE ', rc.update_rule,
' ON DELETE ', rc.delete_rule) AS fk_def
FROM information_schema.key_column_usage kcu
JOIN information_schema.referential_constraints rc
ON kcu.constraint_name = rc.constraint_name
AND kcu.table_schema = rc.constraint_schema
AND kcu.table_name = rc.table_name
WHERE kcu.referenced_table_name IS NOT NULL
AND kcu.table_schema = DATABASE()
) AS fk), ''),
'], "pk_info": [',
IFNULL((SELECT GROUP_CONCAT(
CONCAT('{"schema":"', cast(pk.TABLE_SCHEMA as CHAR),
'","table":"', pk.pk_table,
'","column":"', pk.pk_column,
'","pk_def":"', IFNULL(pk.pk_def, ''), '"}')
) FROM (
SELECT TABLE_SCHEMA,
TABLE_NAME AS pk_table,
COLUMN_NAME AS pk_column,
(SELECT CONCAT('PRIMARY KEY (', GROUP_CONCAT(inc.COLUMN_NAME ORDER BY inc.ORDINAL_POSITION SEPARATOR ', '), ')')
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE as inc
WHERE inc.CONSTRAINT_NAME = 'PRIMARY' and
outc.TABLE_SCHEMA = inc.TABLE_SCHEMA and
outc.TABLE_NAME = inc.TABLE_NAME) AS pk_def
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE as outc
WHERE CONSTRAINT_NAME = 'PRIMARY'
GROUP BY TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME
ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION) AS pk
WHERE table_schema LIKE IFNULL(NULL, '%')
AND table_schema = DATABASE()
AND (0x00) IN (@pk_info:=CONCAT_WS(',', @pk_info, CONCAT('{"schema":"', table_schema,
'","table":"', pk_table,
'","column":"', pk_column,
'","pk_def":"', IFNULL(pk_def, ''),
'"}')))))
), cols as
(
(SELECT (@cols := NULL),
(SELECT (0)
FROM information_schema.columns cols
WHERE cols.table_schema LIKE IFNULL(NULL, '%')
AND cols.table_schema = DATABASE()
AND (0x00) IN (@cols := CONCAT_WS(',', @cols, CONCAT(
'{"schema":"', cols.table_schema,
'","table":"', cols.table_name,
'","name":"', REPLACE(cols.column_name, '"', '\\"'),
'","type":"', LOWER(cols.data_type),
'","character_maximum_length":"', IFNULL(cols.character_maximum_length, 'null'),
'","precision":',
CASE
WHEN cols.data_type IN ('decimal', 'numeric')
THEN CONCAT('{"precision":', IFNULL(cols.numeric_precision, 'null'),
',"scale":', IFNULL(cols.numeric_scale, 'null'), '}')
ELSE 'null'
END,
',"ordinal_position":', cols.ordinal_position,
',"nullable":', IF(cols.is_nullable = 'YES', 'true', 'false'),
',"default":"', ${withExtras ? withDefault : withoutDefault},
'","collation":"', IFNULL(cols.collation_name, ''), '"}'
)))))
), indexes as (
(SELECT (@indexes:=NULL),
(SELECT (0)
FROM information_schema.statistics indexes
WHERE table_schema LIKE IFNULL(NULL, '%')
AND table_schema = DATABASE()
AND (0x00) IN (@indexes:=CONCAT_WS(',', @indexes, CONCAT('{"schema":"',indexes.table_schema,
'","table":"',indexes.table_name,
'","name":"', indexes.index_name,
'","size":',
(SELECT IFNULL(SUM(stat_value * @@innodb_page_size), -1) AS size_in_bytes
FROM mysql.innodb_index_stats
WHERE stat_name = 'size'
AND index_name != 'PRIMARY'
AND index_name = indexes.index_name
AND TABLE_NAME = indexes.table_name
AND database_name = indexes.table_schema),
',"column":"', indexes.column_name,
'","index_type":"', LOWER(indexes.index_type),
'","cardinality":', indexes.cardinality,
',"direction":"', (CASE WHEN indexes.collation = 'D' THEN 'desc' ELSE 'asc' END),
'","column_position":', indexes.seq_in_index,
',"unique":', IF(indexes.non_unique = 1, 'false', 'true'), '}')))))
), tbls as
(
(SELECT (@tbls:=NULL),
(SELECT (0)
FROM information_schema.tables tbls
WHERE table_schema LIKE IFNULL(NULL, '%')
AND table_schema = DATABASE()
AND (0x00) IN (@tbls:=CONCAT_WS(',', @tbls, CONCAT('{', '"schema":"', \`TABLE_SCHEMA\`, '",',
'"table":"', \`TABLE_NAME\`, '",',
'"rows":', IFNULL(\`TABLE_ROWS\`, 0),
', "type":"', IFNULL(\`TABLE_TYPE\`, ''), '",',
'"engine":"', IFNULL(\`ENGINE\`, ''), '",',
'"collation":"', IFNULL(\`TABLE_COLLATION\`, ''), '"}')))))
), views as (
(SELECT (@views:=NULL),
(SELECT (0)
FROM information_schema.views views
WHERE table_schema LIKE IFNULL(NULL, '%')
AND table_schema = DATABASE()
AND (0x00) IN (@views:=CONCAT_WS(',', @views, CONCAT('{', '"schema":"', \`TABLE_SCHEMA\`, '",',
'"view_name":"', \`TABLE_NAME\`, '",',
'"view_definition":""}'))) ) )
)
(SELECT CAST(CONCAT('{"fk_info": [',IFNULL(@fk_info,''),
'], "pk_info": [', IFNULL(@pk_info, ''),
'], "columns": [',IFNULL(@cols,''),
'], "indexes": [',IFNULL(@indexes,''),
'], "tables":[',IFNULL(@tbls,''),
'], "views":[',IFNULL(@views,''),
'], "database_name": "', DATABASE(),
'", "version": "', VERSION(), '"}') AS CHAR) AS metadata_json_to_import
FROM fk_info, pk_info, cols, indexes, tbls, views);
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE as outc
WHERE CONSTRAINT_NAME = 'PRIMARY'
and table_schema LIKE IFNULL(NULL, '%')
AND table_schema = DATABASE()
GROUP BY TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME
) AS pk), ''),
'], "columns": [',
IFNULL((SELECT GROUP_CONCAT(
CONCAT('{"schema":"', cast(cols.table_schema as CHAR),
'","table":"', cols.table_name,
'","name":"', REPLACE(cols.column_name, '"', '\\"'),
'","type":"', LOWER(cols.data_type),
'","character_maximum_length":"', IFNULL(cols.character_maximum_length, 'null'),
'","precision":',
IF(cols.data_type IN ('decimal', 'numeric'),
CONCAT('{"precision":', IFNULL(cols.numeric_precision, 'null'),
',"scale":', IFNULL(cols.numeric_scale, 'null'), '}'), 'null'),
',"ordinal_position":', cols.ordinal_position,
',"nullable":', IF(cols.is_nullable = 'YES', 'true', 'false'),
',"default":"', ${withExtras ? withDefault : withoutDefault},
'","collation":"', IFNULL(cols.collation_name, ''), '"}')
) FROM (
SELECT cols.table_schema,
cols.table_name,
cols.column_name,
LOWER(cols.data_type) AS data_type,
cols.character_maximum_length,
cols.numeric_precision,
cols.numeric_scale,
cols.ordinal_position,
cols.is_nullable,
cols.column_default,
cols.collation_name
FROM information_schema.columns cols
WHERE cols.table_schema = DATABASE()
) AS cols), ''),
'], "indexes": [',
IFNULL((SELECT GROUP_CONCAT(
CONCAT('{"schema":"', cast(idx.table_schema as CHAR),
'","table":"', idx.table_name,
'","name":"', idx.index_name,
'","size":', IFNULL(
(SELECT SUM(stat_value * @@innodb_page_size)
FROM mysql.innodb_index_stats
WHERE stat_name = 'size'
AND index_name != 'PRIMARY'
AND index_name = idx.index_name
AND TABLE_NAME = idx.table_name
AND database_name = idx.table_schema), -1),
',"column":"', idx.column_name,
'","index_type":"', LOWER(idx.index_type),
'","cardinality":', idx.cardinality,
',"direction":"', (CASE WHEN idx.collation = 'D' THEN 'desc' ELSE 'asc' END),
'","column_position":', idx.seq_in_index,
',"unique":', IF(idx.non_unique = 1, 'false', 'true'), '}')
) FROM (
SELECT indexes.table_schema,
indexes.table_name,
indexes.index_name,
indexes.column_name,
LOWER(indexes.index_type) AS index_type,
indexes.cardinality,
indexes.collation,
indexes.non_unique,
indexes.seq_in_index
FROM information_schema.statistics indexes
WHERE indexes.table_schema = DATABASE()
) AS idx), ''),
'], "tables":[',
IFNULL((SELECT GROUP_CONCAT(
CONCAT('{"schema":"', cast(tbls.TABLE_SCHEMA as CHAR),
'","table":"', tbls.TABLE_NAME,
'","rows":', IFNULL(tbls.TABLE_ROWS, 0),
',"type":"', IFNULL(tbls.TABLE_TYPE, ''),
'","engine":"', IFNULL(tbls.ENGINE, ''),
'","collation":"', IFNULL(tbls.TABLE_COLLATION, ''), '"}')
) FROM (
SELECT \`TABLE_SCHEMA\`,
\`TABLE_NAME\`,
\`TABLE_ROWS\`,
\`TABLE_TYPE\`,
\`ENGINE\`,
\`TABLE_COLLATION\`
FROM information_schema.tables tbls
WHERE tbls.table_schema = DATABASE()
) AS tbls), ''),
'], "views":[',
IFNULL((SELECT GROUP_CONCAT(
CONCAT('{"schema":"', cast(vws.TABLE_SCHEMA as CHAR),
'","view_name":"', vws.view_name,
'","view_definition":"', view_definition, '"}')
) FROM (
SELECT \`TABLE_SCHEMA\`,
\`TABLE_NAME\` AS view_name,
null AS view_definition
FROM information_schema.views vws
WHERE vws.table_schema = DATABASE()
) AS vws), ''),
'], "database_name": "', DATABASE(),
'", "version": "', VERSION(), '"}') AS CHAR) AS metadata_json_to_import
`;

View File

@@ -33,6 +33,10 @@ export const getPostgresQuery = (
AND views.schemaname NOT IN ('auth', 'extensions', 'pgsodium', 'realtime', 'storage', 'vault')
`;
const supabaseCustomTypesFilter = `
AND n.nspname NOT IN ('auth', 'extensions', 'pgsodium', 'realtime', 'storage', 'vault')
`;
const timescaleFilters = `
AND connamespace::regnamespace::text !~ '^(timescaledb_|_timescaledb_)'
`;
@@ -55,6 +59,10 @@ export const getPostgresQuery = (
AND views.schemaname !~ '^(timescaledb_|_timescaledb_)'
`;
const timescaleCustomTypesFilter = `
AND n.nspname !~ '^(timescaledb_|_timescaledb_)'
`;
const withExtras = false;
const withDefault = `COALESCE(replace(replace(cols.column_default, '"', '\\"'), '\\x', '\\\\x'), '')`;
@@ -232,7 +240,7 @@ cols AS (
FROM pg_stat_user_tables s
WHERE tbls.TABLE_SCHEMA = s.schemaname AND tbls.TABLE_NAME = s.relname),
0), ', "type":"', tbls.TABLE_TYPE, '",', '"engine":"",', '"collation":"",',
'"comment":"', COALESCE(replace(replace(dsc.description, '"', '\\"'), '\\x', '\\\\x'), ''),
'"comment":"', ${withExtras ? withComments : withoutComments},
'"}'
)),
',') AS tbls_metadata
@@ -282,9 +290,9 @@ cols AS (
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') ${
databaseEdition === DatabaseEdition.POSTGRESQL_TIMESCALE
? timescaleViewsFilter
? timescaleCustomTypesFilter
: databaseEdition === DatabaseEdition.POSTGRESQL_SUPABASE
? supabaseViewsFilter
? supabaseCustomTypesFilter
: ''
}
GROUP BY n.nspname, t.typname
@@ -315,9 +323,9 @@ cols AS (
AND a.attnum > 0 AND NOT a.attisdropped
AND n.nspname NOT IN ('pg_catalog', 'information_schema') ${
databaseEdition === DatabaseEdition.POSTGRESQL_TIMESCALE
? timescaleViewsFilter
? timescaleCustomTypesFilter
: databaseEdition === DatabaseEdition.POSTGRESQL_SUPABASE
? supabaseViewsFilter
? supabaseCustomTypesFilter
: ''
}
GROUP BY n.nspname, t.typname

View File

@@ -1,11 +1,6 @@
import { waitFor } from '@/lib/utils';
import { isDatabaseMetadata } from './metadata-types/database-metadata';
export const fixMetadataJson = async (
metadataJson: string
): Promise<string> => {
await waitFor(1000);
export const fixMetadataJson = (metadataJson: string): string => {
// Replace problematic array default values with null
metadataJson = metadataJson.replace(
/"default": "?'?\[[^\]]*\]'?"?(\\")?(,|\})/gs,

View File

@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import { validateSQL } from '../sql-validator';
import { DatabaseType } from '@/lib/domain';
describe('SQL Validator Auto-fix', () => {
it('should provide auto-fix for cast operator errors', () => {
const sql = `
CREATE TABLE dragons (
id UUID PRIMARY KEY,
lair_location GEOGRAPHY(POINT, 4326)
);
-- Problematic queries with cast operator errors
SELECT id: :text FROM dragons;
SELECT ST_X(lair_location: :geometry) AS longitude FROM dragons;
`;
const result = validateSQL(sql, DatabaseType.POSTGRESQL);
// Should detect errors
expect(result.isValid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
// Should provide fixed SQL
expect(result.fixedSQL).toBeDefined();
// Fixed SQL should have correct cast operators
expect(result.fixedSQL).toContain('::text');
expect(result.fixedSQL).toContain('::geometry');
expect(result.fixedSQL).not.toContain(': :');
// The CREATE TABLE should remain intact
expect(result.fixedSQL).toContain('GEOGRAPHY(POINT, 4326)');
});
it('should handle multi-line cast operator errors', () => {
const sql = `
SELECT AVG(power_level): :DECIMAL(3,
2) FROM enchantments;
`;
const result = validateSQL(sql, DatabaseType.POSTGRESQL);
expect(result.isValid).toBe(false);
expect(result.fixedSQL).toBeDefined();
expect(result.fixedSQL).toContain('::DECIMAL(3,');
expect(result.fixedSQL).not.toContain(': :');
});
it('should auto-fix split DECIMAL declarations', () => {
const sql = `
CREATE TABLE potions (
id INTEGER PRIMARY KEY,
strength DECIMAL(10,
2) NOT NULL,
effectiveness NUMERIC(5,
3) DEFAULT 0.000
);`;
const result = validateSQL(sql, DatabaseType.POSTGRESQL);
expect(result.isValid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
// Should provide fixed SQL
expect(result.fixedSQL).toBeDefined();
// Fixed SQL should have DECIMAL on one line
expect(result.fixedSQL).toContain('DECIMAL(10,2)');
expect(result.fixedSQL).toContain('NUMERIC(5,3)');
expect(result.fixedSQL).not.toMatch(
/DECIMAL\s*\(\s*\d+\s*,\s*\n\s*\d+\s*\)/
);
// Should have warning about auto-fix
expect(
result.warnings.some((w) =>
w.message.includes('Auto-fixed split DECIMAL/NUMERIC')
)
).toBe(true);
});
it('should handle multiple auto-fixes together', () => {
const sql = `
CREATE TABLE enchantments (
id INTEGER PRIMARY KEY,
power_level DECIMAL(10,
2) NOT NULL,
magic_type VARCHAR(50)
);
SELECT AVG(power_level): :DECIMAL(3,
2) FROM enchantments;
`;
const result = validateSQL(sql, DatabaseType.POSTGRESQL);
expect(result.isValid).toBe(false);
expect(result.fixedSQL).toBeDefined();
// Should fix both issues
expect(result.fixedSQL).toContain('DECIMAL(10,2)');
expect(result.fixedSQL).toContain('::DECIMAL(3,');
expect(result.fixedSQL).not.toContain(': :');
// Should have warnings for both fixes
expect(
result.warnings.some((w) =>
w.message.includes('Auto-fixed cast operator')
)
).toBe(true);
expect(
result.warnings.some((w) =>
w.message.includes('Auto-fixed split DECIMAL/NUMERIC')
)
).toBe(true);
});
it('should preserve original SQL when no errors', () => {
const sql = `
CREATE TABLE wizards (
id UUID PRIMARY KEY,
name VARCHAR(100)
);`;
const result = validateSQL(sql, DatabaseType.POSTGRESQL);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.fixedSQL).toBeUndefined();
});
});

View File

@@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest';
import { validateSQL } from '../sql-validator';
import { DatabaseType } from '@/lib/domain';
describe('SQL Validator', () => {
it('should detect cast operator errors (: :)', () => {
const sql = `
CREATE TABLE wizards (
id UUID PRIMARY KEY,
spellbook JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
SELECT id: :text FROM wizards;
SELECT COUNT(*): :integer FROM wizards;
`;
const result = validateSQL(sql, DatabaseType.POSTGRESQL);
expect(result.isValid).toBe(false);
expect(result.errors).toHaveLength(2);
expect(result.errors[0].message).toContain('Invalid cast operator');
expect(result.errors[0].suggestion).toBe('Replace ": :" with "::"');
expect(result.fixedSQL).toBeDefined();
expect(result.fixedSQL).toContain('::text');
expect(result.fixedSQL).toContain('::integer');
});
it('should detect split DECIMAL declarations', () => {
const sql = `
CREATE TABLE potions (
id INTEGER PRIMARY KEY,
power_level DECIMAL(10,
2) NOT NULL
);`;
const result = validateSQL(sql, DatabaseType.POSTGRESQL);
expect(result.isValid).toBe(false);
expect(
result.errors.some((e) =>
e.message.includes('DECIMAL type declaration is split')
)
).toBe(true);
});
it('should warn about extensions', () => {
const sql = `
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION postgis;
CREATE TABLE dragons (id UUID PRIMARY KEY);
`;
const result = validateSQL(sql, DatabaseType.POSTGRESQL);
expect(
result.warnings.some((w) => w.message.includes('CREATE EXTENSION'))
).toBe(true);
});
it('should warn about functions and triggers', () => {
const sql = `
CREATE OR REPLACE FUNCTION update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_wizards_timestamp
BEFORE UPDATE ON wizards
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
`;
const result = validateSQL(sql, DatabaseType.POSTGRESQL);
expect(
result.warnings.some((w) =>
w.message.includes('Function definitions')
)
).toBe(true);
expect(
result.warnings.some((w) =>
w.message.includes('Trigger definitions')
)
).toBe(true);
});
it('should validate clean SQL as valid', () => {
const sql = `
CREATE TABLE wizards (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
magic_email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE spells (
id SERIAL PRIMARY KEY,
wizard_id UUID REFERENCES wizards(id),
name VARCHAR(200) NOT NULL,
incantation TEXT
);
`;
const result = validateSQL(sql, DatabaseType.POSTGRESQL);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.fixedSQL).toBeUndefined();
});
it('should handle the fifth example file issues', () => {
const sql = `
-- Sample from the problematic file
UPDATE magic_towers
SET
power_average = (
SELECT AVG(power): :DECIMAL(3,
2)
FROM enchantments
WHERE tower_id = NEW.tower_id
);
SELECT
ST_X(t.location: :geometry) AS longitude,
ST_Y(t.location: :geometry) AS latitude
FROM towers t;
`;
const result = validateSQL(sql, DatabaseType.POSTGRESQL);
expect(result.isValid).toBe(false);
// Should find multiple cast operator errors
expect(
result.errors.filter((e) =>
e.message.includes('Invalid cast operator')
).length
).toBeGreaterThan(0);
expect(result.fixedSQL).toBeDefined();
expect(result.fixedSQL).not.toContain(': :');
expect(result.fixedSQL).toContain('::DECIMAL');
expect(result.fixedSQL).toContain('::geometry');
});
});

View File

@@ -3,10 +3,13 @@ import { generateDiagramId, generateId } from '@/lib/utils';
import type { DBTable } from '@/lib/domain/db-table';
import type { Cardinality, DBRelationship } from '@/lib/domain/db-relationship';
import type { DBField } from '@/lib/domain/db-field';
import type { DBIndex } from '@/lib/domain/db-index';
import type { DataType } from '@/lib/data/data-types/data-types';
import { genericDataTypes } from '@/lib/data/data-types/generic-data-types';
import { randomColor } 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';
// Common interfaces for SQL entities
export interface SQLColumn {
@@ -15,11 +18,14 @@ export interface SQLColumn {
nullable: boolean;
primaryKey: boolean;
unique: boolean;
typeArgs?: {
length?: number;
precision?: number;
scale?: number;
};
typeArgs?:
| {
length?: number;
precision?: number;
scale?: number;
}
| number[]
| string;
comment?: string;
default?: string;
increment?: boolean;
@@ -62,6 +68,7 @@ export interface SQLParserResult {
relationships: SQLForeignKey[];
types?: SQLCustomType[];
enums?: SQLEnumType[];
warnings?: string[];
}
// Define more specific types for SQL AST nodes
@@ -543,6 +550,50 @@ export function convertToChartDBDiagram(
) {
// Ensure integer types are preserved
mappedType = { id: 'integer', name: 'integer' };
} else if (
sourceDatabaseType === DatabaseType.POSTGRESQL &&
parserResult.enums &&
parserResult.enums.some(
(e) => e.name.toLowerCase() === column.type.toLowerCase()
)
) {
// If the column type matches a custom enum type, preserve it
mappedType = {
id: column.type.toLowerCase(),
name: column.type,
};
}
// Handle SQL Server types specifically
else if (
sourceDatabaseType === DatabaseType.SQL_SERVER &&
targetDatabaseType === DatabaseType.SQL_SERVER
) {
const normalizedType = column.type.toLowerCase();
// Preserve SQL Server specific types when target is also SQL Server
if (
normalizedType === 'nvarchar' ||
normalizedType === 'nchar' ||
normalizedType === 'ntext' ||
normalizedType === 'uniqueidentifier' ||
normalizedType === 'datetime2' ||
normalizedType === 'datetimeoffset' ||
normalizedType === 'money' ||
normalizedType === 'smallmoney' ||
normalizedType === 'bit' ||
normalizedType === 'xml' ||
normalizedType === 'hierarchyid' ||
normalizedType === 'geography' ||
normalizedType === 'geometry'
) {
mappedType = { id: normalizedType, name: normalizedType };
} else {
// Use the standard mapping for other types
mappedType = mapSQLTypeToGenericType(
column.type,
sourceDatabaseType
);
}
} else {
// Use the standard mapping for other types
mappedType = mapSQLTypeToGenericType(
@@ -565,22 +616,68 @@ export function convertToChartDBDiagram(
// Add type arguments if present
if (column.typeArgs) {
// Transfer length for varchar/char types
if (
column.typeArgs.length !== undefined &&
(field.type.id === 'varchar' || field.type.id === 'char')
) {
field.characterMaximumLength =
column.typeArgs.length.toString();
// Handle string typeArgs (e.g., 'max' for varchar(max))
if (typeof column.typeArgs === 'string') {
if (
(field.type.id === 'varchar' ||
field.type.id === 'nvarchar') &&
column.typeArgs === 'max'
) {
field.characterMaximumLength = 'max';
}
}
// Transfer precision/scale for numeric types
if (
column.typeArgs.precision !== undefined &&
(field.type.id === 'numeric' || field.type.id === 'decimal')
// Handle array typeArgs (SQL Server format)
else if (
Array.isArray(column.typeArgs) &&
column.typeArgs.length > 0
) {
field.precision = column.typeArgs.precision;
field.scale = column.typeArgs.scale;
if (
field.type.id === 'varchar' ||
field.type.id === 'nvarchar' ||
field.type.id === 'char' ||
field.type.id === 'nchar'
) {
field.characterMaximumLength =
column.typeArgs[0].toString();
} else if (
(field.type.id === 'numeric' ||
field.type.id === 'decimal') &&
column.typeArgs.length >= 2
) {
field.precision = column.typeArgs[0];
field.scale = column.typeArgs[1];
}
}
// Handle object typeArgs (standard format)
else if (
typeof column.typeArgs === 'object' &&
!Array.isArray(column.typeArgs)
) {
const typeArgsObj = column.typeArgs as {
length?: number;
precision?: number;
scale?: number;
};
// Transfer length for varchar/char types
if (
typeArgsObj.length !== undefined &&
(field.type.id === 'varchar' ||
field.type.id === 'char')
) {
field.characterMaximumLength =
typeArgsObj.length.toString();
}
// Transfer precision/scale for numeric types
if (
typeArgsObj.precision !== undefined &&
(field.type.id === 'numeric' ||
field.type.id === 'decimal')
) {
field.precision = typeArgsObj.precision;
field.scale = typeArgsObj.scale;
}
}
}
@@ -588,25 +685,38 @@ export function convertToChartDBDiagram(
});
// Create indexes
const indexes = table.indexes.map((sqlIndex) => {
const fieldIds = sqlIndex.columns.map((columnName) => {
const field = fields.find((f) => f.name === columnName);
if (!field) {
throw new Error(
`Index references non-existent column: ${columnName}`
);
}
return field.id;
});
const indexes = table.indexes
.map((sqlIndex) => {
const fieldIds = sqlIndex.columns
.map((columnName) => {
const field = fields.find((f) => f.name === columnName);
if (!field) {
console.warn(
`Index ${sqlIndex.name} references non-existent column: ${columnName} in table ${table.name}. Skipping this column.`
);
return null;
}
return field.id;
})
.filter((id): id is string => id !== null);
return {
id: generateId(),
name: sqlIndex.name,
fieldIds,
unique: sqlIndex.unique,
createdAt: Date.now(),
};
});
// Only create index if at least one column was found
if (fieldIds.length === 0) {
console.warn(
`Index ${sqlIndex.name} has no valid columns. Skipping index.`
);
return null;
}
return {
id: generateId(),
name: sqlIndex.name,
fieldIds,
unique: sqlIndex.unique,
createdAt: Date.now(),
};
})
.filter((idx): idx is DBIndex => idx !== null);
return {
id: newId,
@@ -708,12 +818,29 @@ export function convertToChartDBDiagram(
});
});
// Convert SQL enum types to ChartDB custom types
const customTypes: DBCustomType[] = [];
if (parserResult.enums) {
parserResult.enums.forEach((enumType, index) => {
customTypes.push({
id: generateId(),
name: enumType.name,
schema: 'public', // Default to public schema for now
kind: DBCustomTypeKind.enum,
values: enumType.values,
order: index,
});
});
}
const diagram = {
id: generateDiagramId(),
name: `SQL Import (${sourceDatabaseType})`,
databaseType: targetDatabaseType,
tables,
relationships,
customTypes: customTypes.length > 0 ? customTypes : undefined,
createdAt: new Date(),
updatedAt: new Date(),
};

View File

@@ -0,0 +1,458 @@
import { describe, it, expect } from 'vitest';
import { fromPostgres } from '../postgresql';
describe('PostgreSQL Core Parser Tests', () => {
it('should parse basic tables', async () => {
const sql = `
CREATE TABLE wizards (
id INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.tables[0].name).toBe('wizards');
expect(result.tables[0].columns).toHaveLength(2);
});
it('should parse foreign key relationships', async () => {
const sql = `
CREATE TABLE guilds (id INTEGER PRIMARY KEY);
CREATE TABLE mages (
id INTEGER PRIMARY KEY,
guild_id INTEGER REFERENCES guilds(id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(2);
expect(result.relationships).toHaveLength(1);
expect(result.relationships[0].sourceTable).toBe('mages');
expect(result.relationships[0].targetTable).toBe('guilds');
});
it('should skip functions with warnings', async () => {
const sql = `
CREATE TABLE test_table (id INTEGER PRIMARY KEY);
CREATE FUNCTION test_func() RETURNS VOID AS $$
BEGIN
NULL;
END;
$$ LANGUAGE plpgsql;
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.warnings).toBeDefined();
expect(result.warnings!.some((w) => w.includes('Function'))).toBe(true);
});
it('should handle tables that fail to parse', async () => {
const sql = `
CREATE TABLE valid_table (id INTEGER PRIMARY KEY);
-- This table has syntax that might fail parsing
CREATE TABLE complex_table (
id INTEGER PRIMARY KEY,
value NUMERIC(10,
2) GENERATED ALWAYS AS (1 + 1) STORED
);
CREATE TABLE another_valid (
id INTEGER PRIMARY KEY,
complex_ref INTEGER REFERENCES complex_table(id)
);
`;
const result = await fromPostgres(sql);
// Should find all 3 tables even if complex_table fails to parse
expect(result.tables).toHaveLength(3);
expect(result.tables.map((t) => t.name).sort()).toEqual([
'another_valid',
'complex_table',
'valid_table',
]);
// Should still find the foreign key relationship
expect(
result.relationships.some(
(r) =>
r.sourceTable === 'another_valid' &&
r.targetTable === 'complex_table'
)
).toBe(true);
});
it('should parse the magical academy system fixture', async () => {
const sql = `-- Magical Academy System Database Schema
-- This is a test fixture representing a typical magical academy system
CREATE TABLE magic_schools(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE towers(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES magic_schools(id) ON DELETE CASCADE,
name text NOT NULL,
location text,
crystal_frequency varchar(20),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE magical_ranks(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES magic_schools(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
is_system boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE spell_permissions(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
spell_school text NOT NULL,
spell_action text NOT NULL,
description text,
UNIQUE (spell_school, spell_action)
);
CREATE TABLE rank_permissions(
rank_id uuid NOT NULL REFERENCES magical_ranks(id) ON DELETE CASCADE,
permission_id uuid NOT NULL REFERENCES spell_permissions(id) ON DELETE CASCADE,
granted_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (rank_id, permission_id)
);
CREATE TABLE grimoire_types(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES magic_schools(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
is_active boolean NOT NULL DEFAULT true
);
CREATE TABLE wizards(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES magic_schools(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
username text NOT NULL,
email text NOT NULL,
password_hash text NOT NULL,
first_name text NOT NULL,
last_name text NOT NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (school_id, username),
UNIQUE (email)
);
-- This function should not prevent the next table from being parsed
CREATE FUNCTION enforce_wizard_tower_school()
RETURNS TRIGGER AS $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM towers
WHERE id = NEW.tower_id AND school_id = NEW.school_id
) THEN
RAISE EXCEPTION 'Tower does not belong to magic school';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TABLE wizard_ranks(
wizard_id uuid NOT NULL REFERENCES wizards(id) ON DELETE CASCADE,
rank_id uuid NOT NULL REFERENCES magical_ranks(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
assigned_at timestamptz NOT NULL DEFAULT now(),
assigned_by uuid REFERENCES wizards(id),
PRIMARY KEY (wizard_id, rank_id, tower_id)
);
CREATE TABLE apprentices(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES magic_schools(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
apprentice_id text NOT NULL, -- Magical Apprentice Identifier
first_name text NOT NULL,
last_name text NOT NULL,
date_of_birth date NOT NULL,
magical_affinity varchar(10),
email text,
crystal_phone varchar(20),
dormitory text,
emergency_contact jsonb,
patron_info jsonb,
primary_mentor uuid REFERENCES wizards(id),
referring_wizard uuid REFERENCES wizards(id),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (school_id, apprentice_id)
);
CREATE TABLE spell_lessons(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES magic_schools(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
apprentice_id uuid NOT NULL REFERENCES apprentices(id) ON DELETE CASCADE,
instructor_id uuid NOT NULL REFERENCES wizards(id),
lesson_date timestamptz NOT NULL,
duration_minutes integer NOT NULL DEFAULT 30,
status text NOT NULL DEFAULT 'scheduled',
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NOT NULL REFERENCES wizards(id),
CONSTRAINT valid_status CHECK (status IN ('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show'))
);
CREATE TABLE grimoires(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES magic_schools(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
apprentice_id uuid NOT NULL REFERENCES apprentices(id) ON DELETE CASCADE,
lesson_id uuid REFERENCES spell_lessons(id),
grimoire_type_id uuid NOT NULL REFERENCES grimoire_types(id),
instructor_id uuid NOT NULL REFERENCES wizards(id),
content jsonb NOT NULL,
enchantments jsonb,
is_sealed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE tuition_scrolls(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES magic_schools(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
apprentice_id uuid NOT NULL REFERENCES apprentices(id) ON DELETE CASCADE,
scroll_number text NOT NULL,
scroll_date date NOT NULL DEFAULT CURRENT_DATE,
due_date date NOT NULL,
subtotal numeric(10,2) NOT NULL,
magical_tax numeric(10,2) NOT NULL DEFAULT 0,
scholarship_amount numeric(10,2) NOT NULL DEFAULT 0,
total_gold numeric(10,2) NOT NULL,
status text NOT NULL DEFAULT 'draft',
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NOT NULL REFERENCES wizards(id),
UNIQUE (school_id, scroll_number),
CONSTRAINT valid_scroll_status CHECK (status IN ('draft', 'sent', 'paid', 'overdue', 'cancelled'))
);
CREATE TABLE scroll_line_items(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scroll_id uuid NOT NULL REFERENCES tuition_scrolls(id) ON DELETE CASCADE,
description text NOT NULL,
quantity numeric(10,2) NOT NULL DEFAULT 1,
gold_per_unit numeric(10,2) NOT NULL,
total_gold numeric(10,2) NOT NULL,
lesson_id uuid REFERENCES spell_lessons(id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE patron_sponsorships(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scroll_id uuid NOT NULL REFERENCES tuition_scrolls(id) ON DELETE CASCADE,
patron_house text NOT NULL,
sponsorship_code text NOT NULL,
claim_number text NOT NULL,
claim_date date NOT NULL DEFAULT CURRENT_DATE,
gold_requested numeric(10,2) NOT NULL,
gold_approved numeric(10,2),
status text NOT NULL DEFAULT 'submitted',
denial_reason text,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (claim_number),
CONSTRAINT valid_sponsorship_status CHECK (status IN ('draft', 'submitted', 'in_review', 'approved', 'partial', 'denied', 'appealed'))
);
CREATE TABLE gold_payments(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scroll_id uuid NOT NULL REFERENCES tuition_scrolls(id) ON DELETE CASCADE,
payment_date timestamptz NOT NULL DEFAULT now(),
gold_amount numeric(10,2) NOT NULL,
payment_method text NOT NULL,
reference_rune text,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NOT NULL REFERENCES wizards(id),
CONSTRAINT valid_payment_method CHECK (payment_method IN ('gold_coins', 'crystal_transfer', 'mithril_card', 'dragon_scale', 'patron_sponsorship', 'other'))
);
CREATE TABLE arcane_logs(
id bigserial PRIMARY KEY,
school_id uuid,
wizard_id uuid,
tower_id uuid,
table_name text NOT NULL,
record_id uuid,
spell_operation text NOT NULL,
old_values jsonb,
new_values jsonb,
casting_source inet,
magical_signature text,
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (school_id) REFERENCES magic_schools(id) ON DELETE SET NULL,
FOREIGN KEY (wizard_id) REFERENCES wizards(id) ON DELETE SET NULL,
FOREIGN KEY (tower_id) REFERENCES towers(id) ON DELETE SET NULL,
CONSTRAINT valid_spell_operation CHECK (spell_operation IN ('INSERT', 'UPDATE', 'DELETE'))
);
-- Enable Row Level Security
ALTER TABLE wizards ENABLE ROW LEVEL SECURITY;
ALTER TABLE apprentices ENABLE ROW LEVEL SECURITY;
ALTER TABLE grimoires ENABLE ROW LEVEL SECURITY;
ALTER TABLE spell_lessons ENABLE ROW LEVEL SECURITY;
ALTER TABLE tuition_scrolls ENABLE ROW LEVEL SECURITY;
-- Create RLS Policies
CREATE POLICY school_isolation_wizards ON wizards
FOR ALL TO authenticated
USING (school_id = current_setting('app.current_school')::uuid);
CREATE POLICY school_isolation_apprentices ON apprentices
FOR ALL TO authenticated
USING (school_id = current_setting('app.current_school')::uuid);
-- Create arcane audit trigger function
CREATE FUNCTION arcane_audit_trigger()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO arcane_logs (
school_id,
wizard_id,
tower_id,
table_name,
record_id,
spell_operation,
old_values,
new_values
) VALUES (
current_setting('app.current_school', true)::uuid,
current_setting('app.current_wizard', true)::uuid,
current_setting('app.current_tower', true)::uuid,
TG_TABLE_NAME,
COALESCE(NEW.id, OLD.id),
TG_OP,
CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN to_jsonb(OLD) ELSE NULL END,
CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN to_jsonb(NEW) ELSE NULL END
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create triggers
CREATE TRIGGER arcane_audit_wizards AFTER INSERT OR UPDATE OR DELETE ON wizards
FOR EACH ROW EXECUTE FUNCTION arcane_audit_trigger();
CREATE TRIGGER arcane_audit_apprentices AFTER INSERT OR UPDATE OR DELETE ON apprentices
FOR EACH ROW EXECUTE FUNCTION arcane_audit_trigger();`;
const result = await fromPostgres(sql);
// Should find all 16 tables
expect(result.tables).toHaveLength(16);
const tableNames = result.tables.map((t) => t.name).sort();
const expectedTables = [
'apprentices',
'arcane_logs',
'gold_payments',
'grimoire_types',
'grimoires',
'magic_schools',
'magical_ranks',
'patron_sponsorships',
'rank_permissions',
'scroll_line_items',
'spell_lessons',
'spell_permissions',
'towers',
'tuition_scrolls',
'wizard_ranks',
'wizards',
];
expect(tableNames).toEqual(expectedTables);
// Should have many relationships
expect(result.relationships.length).toBeGreaterThan(30);
// Should have warnings about unsupported features
expect(result.warnings).toBeDefined();
expect(result.warnings!.length).toBeGreaterThan(0);
// Verify specific critical relationships exist
const hasWizardSchoolFK = result.relationships.some(
(r) =>
r.sourceTable === 'wizards' &&
r.targetTable === 'magic_schools' &&
r.sourceColumn === 'school_id'
);
expect(hasWizardSchoolFK).toBe(true);
const hasApprenticeMentorFK = result.relationships.some(
(r) =>
r.sourceTable === 'apprentices' &&
r.targetTable === 'wizards' &&
r.sourceColumn === 'primary_mentor'
);
expect(hasApprenticeMentorFK).toBe(true);
});
it('should handle ALTER TABLE ENABLE ROW LEVEL SECURITY', async () => {
const sql = `
CREATE TABLE secure_table (id INTEGER PRIMARY KEY);
ALTER TABLE secure_table ENABLE ROW LEVEL SECURITY;
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.warnings).toBeDefined();
// The warning should mention row level security
expect(
result.warnings!.some((w) =>
w.toLowerCase().includes('row level security')
)
).toBe(true);
});
it('should extract foreign keys even from unparsed tables', async () => {
const sql = `
CREATE TABLE base (id UUID PRIMARY KEY);
-- Intentionally malformed to fail parsing
CREATE TABLE malformed (
id UUID PRIMARY KEY,
base_id UUID REFERENCES base(id),
FOREIGN KEY (base_id) REFERENCES base(id) ON DELETE CASCADE,
value NUMERIC(10,
2) -- Missing closing paren will cause parse failure
`;
const result = await fromPostgres(sql);
// Should still create the table entry
expect(result.tables.map((t) => t.name)).toContain('malformed');
// Should extract the foreign key
const fks = result.relationships.filter(
(r) => r.sourceTable === 'malformed'
);
expect(fks.length).toBeGreaterThan(0);
expect(fks[0].targetTable).toBe('base');
});
});

View File

@@ -0,0 +1,330 @@
import { describe, it, expect } from 'vitest';
import { fromPostgres } from '../postgresql';
describe('PostgreSQL Real-World Examples', () => {
describe('Magical Academy Example', () => {
it('should parse the magical academy example with all 16 tables', async () => {
const sql = `
CREATE TABLE schools(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE towers(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
name text NOT NULL
);
CREATE TABLE ranks(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
name text NOT NULL
);
CREATE TABLE spell_permissions(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
spell_type text NOT NULL,
casting_level text NOT NULL
);
CREATE TABLE rank_spell_permissions(
rank_id uuid NOT NULL REFERENCES ranks(id) ON DELETE CASCADE,
spell_permission_id uuid NOT NULL REFERENCES spell_permissions(id) ON DELETE CASCADE,
PRIMARY KEY (rank_id, spell_permission_id)
);
CREATE TABLE grimoire_types(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
name text NOT NULL
);
CREATE TABLE wizards(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
wizard_name text NOT NULL,
email text NOT NULL,
UNIQUE (school_id, wizard_name)
);
CREATE FUNCTION enforce_wizard_tower_school()
RETURNS TRIGGER AS $$
BEGIN
-- Function body
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TABLE wizard_ranks(
wizard_id uuid NOT NULL REFERENCES wizards(id) ON DELETE CASCADE,
rank_id uuid NOT NULL REFERENCES ranks(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
assigned_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (wizard_id, rank_id, tower_id)
);
CREATE TABLE apprentices(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
first_name text NOT NULL,
last_name text NOT NULL,
enrollment_date date NOT NULL,
primary_mentor uuid REFERENCES wizards(id),
sponsoring_wizard uuid REFERENCES wizards(id)
);
CREATE TABLE spell_lessons(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
apprentice_id uuid NOT NULL REFERENCES apprentices(id) ON DELETE CASCADE,
instructor_id uuid NOT NULL REFERENCES wizards(id),
lesson_date timestamptz NOT NULL
);
CREATE TABLE grimoires(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
apprentice_id uuid NOT NULL REFERENCES apprentices(id) ON DELETE CASCADE,
grimoire_type_id uuid NOT NULL REFERENCES grimoire_types(id),
author_wizard_id uuid NOT NULL REFERENCES wizards(id),
content jsonb NOT NULL
);
CREATE TABLE tuition_scrolls(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
apprentice_id uuid NOT NULL REFERENCES apprentices(id) ON DELETE CASCADE,
total_amount numeric(10,2) NOT NULL,
status text NOT NULL
);
CREATE TABLE tuition_items(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tuition_scroll_id uuid NOT NULL REFERENCES tuition_scrolls(id) ON DELETE CASCADE,
description text NOT NULL,
amount numeric(10,2) NOT NULL
);
CREATE TABLE patron_sponsorships(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tuition_scroll_id uuid NOT NULL REFERENCES tuition_scrolls(id) ON DELETE CASCADE,
patron_house text NOT NULL,
sponsorship_code text NOT NULL,
status text NOT NULL
);
CREATE TABLE gold_payments(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tuition_scroll_id uuid NOT NULL REFERENCES tuition_scrolls(id) ON DELETE CASCADE,
amount numeric(10,2) NOT NULL,
payment_date timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE arcane_logs(
id bigserial PRIMARY KEY,
school_id uuid,
wizard_id uuid,
tower_id uuid,
table_name text NOT NULL,
operation text NOT NULL,
record_id uuid,
changes jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL,
FOREIGN KEY (wizard_id) REFERENCES wizards(id) ON DELETE SET NULL,
FOREIGN KEY (tower_id) REFERENCES towers(id) ON DELETE SET NULL
);
-- Enable RLS
ALTER TABLE wizards ENABLE ROW LEVEL SECURITY;
ALTER TABLE apprentices ENABLE ROW LEVEL SECURITY;
-- Create policies
CREATE POLICY school_isolation ON wizards
FOR ALL TO public
USING (school_id = current_setting('app.current_school')::uuid);
`;
const result = await fromPostgres(sql);
// Should find all 16 tables
const expectedTables = [
'apprentices',
'arcane_logs',
'gold_payments',
'grimoire_types',
'grimoires',
'patron_sponsorships',
'rank_spell_permissions',
'ranks',
'schools',
'spell_lessons',
'spell_permissions',
'towers',
'tuition_items',
'tuition_scrolls',
'wizard_ranks',
'wizards',
];
expect(result.tables).toHaveLength(16);
expect(result.tables.map((t) => t.name).sort()).toEqual(
expectedTables
);
// Verify key relationships exist
const relationships = result.relationships;
// Check some critical relationships
expect(
relationships.some(
(r) =>
r.sourceTable === 'wizards' &&
r.targetTable === 'schools' &&
r.sourceColumn === 'school_id'
)
).toBe(true);
expect(
relationships.some(
(r) =>
r.sourceTable === 'wizard_ranks' &&
r.targetTable === 'wizards' &&
r.sourceColumn === 'wizard_id'
)
).toBe(true);
expect(
relationships.some(
(r) =>
r.sourceTable === 'apprentices' &&
r.targetTable === 'wizards' &&
r.sourceColumn === 'primary_mentor'
)
).toBe(true);
// Should have warnings about functions, policies, and RLS
expect(result.warnings).toBeDefined();
expect(result.warnings!.length).toBeGreaterThan(0);
});
});
describe('Enchanted Bazaar Example', () => {
it('should parse the enchanted bazaar example with functions and policies', async () => {
const sql = `
-- Enchanted Bazaar tables with complex features
CREATE TABLE merchants(
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE artifacts(
id SERIAL PRIMARY KEY,
merchant_id INTEGER REFERENCES merchants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL CHECK (price >= 0),
enchantment_charges INTEGER DEFAULT 0 CHECK (enchantment_charges >= 0)
);
-- Function that should be skipped
CREATE FUNCTION consume_charges(artifact_id INTEGER, charges_used INTEGER)
RETURNS VOID AS $$
BEGIN
UPDATE artifacts SET enchantment_charges = enchantment_charges - charges_used WHERE id = artifact_id;
END;
$$ LANGUAGE plpgsql;
CREATE TABLE trades(
id SERIAL PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(50) DEFAULT 'negotiating'
);
CREATE TABLE trade_items(
trade_id INTEGER REFERENCES trades(id) ON DELETE CASCADE,
artifact_id INTEGER REFERENCES artifacts(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
agreed_price DECIMAL(10, 2) NOT NULL,
PRIMARY KEY (trade_id, artifact_id)
);
-- Enable RLS
ALTER TABLE artifacts ENABLE ROW LEVEL SECURITY;
-- Create policy
CREATE POLICY merchant_artifacts ON artifacts
FOR ALL TO merchants
USING (merchant_id = current_user_id());
-- Create trigger
CREATE TRIGGER charge_consumption_trigger
AFTER INSERT ON trade_items
FOR EACH ROW
EXECUTE FUNCTION consume_charges();
`;
const result = await fromPostgres(sql);
// Should parse all tables despite functions, policies, and triggers
expect(result.tables.length).toBeGreaterThanOrEqual(4);
// Check for specific tables
const tableNames = result.tables.map((t) => t.name);
expect(tableNames).toContain('merchants');
expect(tableNames).toContain('artifacts');
expect(tableNames).toContain('trades');
expect(tableNames).toContain('trade_items');
// Check relationships
if (tableNames.includes('marketplace_tokens')) {
// Real file relationships
expect(
result.relationships.some(
(r) =>
r.sourceTable === 'marketplace_listings' &&
r.targetTable === 'inventory_items'
)
).toBe(true);
} else {
// Mock data relationships
expect(
result.relationships.some(
(r) =>
r.sourceTable === 'artifacts' &&
r.targetTable === 'merchants'
)
).toBe(true);
expect(
result.relationships.some(
(r) =>
r.sourceTable === 'trade_items' &&
r.targetTable === 'trades'
)
).toBe(true);
}
// Should have warnings about unsupported features
if (result.warnings) {
expect(
result.warnings.some(
(w) =>
w.includes('Function') ||
w.includes('Policy') ||
w.includes('Trigger') ||
w.includes('ROW LEVEL SECURITY')
)
).toBe(true);
}
});
});
});

View File

@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import { fromPostgres } from '../postgresql';
describe('PostgreSQL Parser Integration', () => {
it('should parse simple SQL', async () => {
const sql = `
CREATE TABLE wizards (
id INTEGER PRIMARY KEY,
name VARCHAR(255)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.tables[0].name).toBe('wizards');
});
it('should handle functions correctly', async () => {
const sql = `
CREATE TABLE wizards (id INTEGER PRIMARY KEY);
CREATE FUNCTION get_wizard() RETURNS INTEGER AS $$
BEGIN
RETURN 1;
END;
$$ LANGUAGE plpgsql;
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.tables[0].name).toBe('wizards');
});
it('should handle policies correctly', async () => {
const sql = `
CREATE TABLE ancient_scrolls (id INTEGER PRIMARY KEY);
CREATE POLICY wizard_policy ON ancient_scrolls
FOR SELECT
USING (true);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
});
it('should handle RLS correctly', async () => {
const sql = `
CREATE TABLE enchanted_vault (id INTEGER PRIMARY KEY);
ALTER TABLE enchanted_vault ENABLE ROW LEVEL SECURITY;
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
});
it('should handle triggers correctly', async () => {
const sql = `
CREATE TABLE spell_log (id INTEGER PRIMARY KEY);
CREATE TRIGGER spell_trigger
AFTER INSERT ON spell_log
FOR EACH ROW
EXECUTE FUNCTION spell_func();
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
});
it('should preserve all relationships', async () => {
const sql = `
CREATE TABLE guilds (id INTEGER PRIMARY KEY);
CREATE TABLE wizards (
id INTEGER PRIMARY KEY,
guild_id INTEGER REFERENCES guilds(id)
);
-- This function should trigger improved parser
CREATE FUNCTION dummy() RETURNS VOID AS $$ BEGIN END; $$ LANGUAGE plpgsql;
CREATE TABLE quests (
id INTEGER PRIMARY KEY,
wizard_id INTEGER REFERENCES wizards(id),
guild_id INTEGER REFERENCES guilds(id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(3);
expect(result.relationships).toHaveLength(3);
// Verify all relationships are preserved
expect(
result.relationships.some(
(r) => r.sourceTable === 'wizards' && r.targetTable === 'guilds'
)
).toBe(true);
expect(
result.relationships.some(
(r) => r.sourceTable === 'quests' && r.targetTable === 'wizards'
)
).toBe(true);
expect(
result.relationships.some(
(r) => r.sourceTable === 'quests' && r.targetTable === 'guilds'
)
).toBe(true);
});
});

View File

@@ -0,0 +1,491 @@
import { describe, it, expect } from 'vitest';
import { fromPostgres } from '../postgresql';
describe('PostgreSQL Parser', () => {
describe('Basic Table Parsing', () => {
it('should parse simple tables with basic data types', async () => {
const sql = `
CREATE TABLE wizards (
id INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL,
magic_email TEXT UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.tables[0].name).toBe('wizards');
expect(result.tables[0].columns).toHaveLength(4);
expect(result.tables[0].columns[0].name).toBe('id');
expect(result.tables[0].columns[0].type).toBe('INTEGER');
expect(result.tables[0].columns[0].primaryKey).toBe(true);
});
it('should parse multiple tables', async () => {
const sql = `
CREATE TABLE guilds (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
CREATE TABLE mages (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
guild_id INTEGER REFERENCES guilds(id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(2);
expect(result.tables.map((t) => t.name).sort()).toEqual([
'guilds',
'mages',
]);
expect(result.relationships).toHaveLength(1);
expect(result.relationships[0].sourceTable).toBe('mages');
expect(result.relationships[0].targetTable).toBe('guilds');
});
it('should handle IF NOT EXISTS clause', async () => {
const sql = `
CREATE TABLE IF NOT EXISTS potions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.tables[0].name).toBe('potions');
});
});
describe('Complex Data Types', () => {
it('should handle UUID and special PostgreSQL types', async () => {
const sql = `
CREATE TABLE special_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
data JSONB,
tags TEXT[],
location POINT,
mana_cost MONEY,
binary_data BYTEA
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const columns = result.tables[0].columns;
expect(columns.find((c) => c.name === 'id')?.type).toBe('UUID');
expect(columns.find((c) => c.name === 'data')?.type).toBe('JSONB');
expect(columns.find((c) => c.name === 'tags')?.type).toBe('TEXT[]');
});
it('should handle numeric with precision', async () => {
const sql = `
CREATE TABLE treasury (
id SERIAL PRIMARY KEY,
amount NUMERIC(10, 2),
percentage DECIMAL(5, 2),
big_number BIGINT
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const columns = result.tables[0].columns;
// Parser limitation: scale on separate line is not captured
const amountType = columns.find((c) => c.name === 'amount')?.type;
expect(amountType).toMatch(/^NUMERIC/);
});
it('should handle multi-line numeric definitions', async () => {
const sql = `
CREATE TABLE multi_line (
id INTEGER PRIMARY KEY,
value NUMERIC(10,
2),
another_col TEXT
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.tables[0].columns).toHaveLength(3);
});
});
describe('Foreign Key Relationships', () => {
it('should parse inline foreign keys', async () => {
const sql = `
CREATE TABLE realms (id INTEGER PRIMARY KEY);
CREATE TABLE sanctuaries (
id INTEGER PRIMARY KEY,
realm_id INTEGER REFERENCES realms(id)
);
`;
const result = await fromPostgres(sql);
expect(result.relationships).toHaveLength(1);
expect(result.relationships[0].sourceTable).toBe('sanctuaries');
expect(result.relationships[0].targetTable).toBe('realms');
expect(result.relationships[0].sourceColumn).toBe('realm_id');
expect(result.relationships[0].targetColumn).toBe('id');
});
it('should parse table-level foreign key constraints', async () => {
const sql = `
CREATE TABLE enchantment_orders (id INTEGER PRIMARY KEY);
CREATE TABLE enchantment_items (
id INTEGER PRIMARY KEY,
order_id INTEGER,
CONSTRAINT fk_order FOREIGN KEY (order_id) REFERENCES enchantment_orders(id)
);
`;
const result = await fromPostgres(sql);
expect(result.relationships).toHaveLength(1);
expect(result.relationships[0].sourceTable).toBe(
'enchantment_items'
);
expect(result.relationships[0].targetTable).toBe(
'enchantment_orders'
);
});
it('should parse composite foreign keys', async () => {
const sql = `
CREATE TABLE magic_schools (id UUID PRIMARY KEY);
CREATE TABLE quests (
school_id UUID,
quest_id UUID,
name TEXT,
PRIMARY KEY (school_id, quest_id),
FOREIGN KEY (school_id) REFERENCES magic_schools(id)
);
CREATE TABLE rituals (
id UUID PRIMARY KEY,
school_id UUID,
quest_id UUID,
FOREIGN KEY (school_id, quest_id) REFERENCES quests(school_id, quest_id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(3);
// Composite foreign keys are not fully supported
expect(result.relationships).toHaveLength(1);
expect(result.relationships[0].sourceTable).toBe('quests');
expect(result.relationships[0].targetTable).toBe('magic_schools');
});
it('should handle ON DELETE and ON UPDATE clauses', async () => {
const sql = `
CREATE TABLE wizards (id INTEGER PRIMARY KEY);
CREATE TABLE scrolls (
id INTEGER PRIMARY KEY,
wizard_id INTEGER REFERENCES wizards(id) ON DELETE CASCADE ON UPDATE CASCADE
);
`;
const result = await fromPostgres(sql);
expect(result.relationships).toHaveLength(1);
// ON DELETE/UPDATE clauses are not preserved in output
});
});
describe('Constraints', () => {
it('should parse unique constraints', async () => {
const sql = `
CREATE TABLE wizards (
id INTEGER PRIMARY KEY,
magic_email TEXT UNIQUE,
wizard_name TEXT,
UNIQUE (wizard_name)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const columns = result.tables[0].columns;
expect(columns.find((c) => c.name === 'magic_email')?.unique).toBe(
true
);
});
it('should parse check constraints', async () => {
const sql = `
CREATE TABLE potions (
id INTEGER PRIMARY KEY,
mana_cost DECIMAL CHECK (mana_cost > 0),
quantity INTEGER,
CONSTRAINT positive_quantity CHECK (quantity >= 0)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.tables[0].columns).toHaveLength(3);
});
it('should parse composite primary keys', async () => {
const sql = `
CREATE TABLE enchantment_items (
order_id INTEGER,
potion_id INTEGER,
quantity INTEGER,
PRIMARY KEY (order_id, potion_id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
const columns = result.tables[0].columns;
expect(columns.filter((c) => c.primaryKey)).toHaveLength(2);
});
});
describe('Generated Columns', () => {
it('should handle GENERATED ALWAYS AS IDENTITY', async () => {
const sql = `
CREATE TABLE items (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.tables[0].columns[0].increment).toBe(true);
});
it('should handle GENERATED BY DEFAULT AS IDENTITY', async () => {
const sql = `
CREATE TABLE items (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name TEXT
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.tables[0].columns[0].increment).toBe(true);
});
it('should handle computed columns', async () => {
const sql = `
CREATE TABLE calculations (
id INTEGER PRIMARY KEY,
value1 NUMERIC,
value2 NUMERIC,
total NUMERIC GENERATED ALWAYS AS (value1 + value2) STORED
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.tables[0].columns).toHaveLength(4);
});
});
describe('Unsupported Statements', () => {
it('should skip and warn about functions', async () => {
const sql = `
CREATE TABLE wizards (id INTEGER PRIMARY KEY);
CREATE FUNCTION get_wizard_name(wizard_id INTEGER)
RETURNS TEXT AS $$
BEGIN
RETURN 'test';
END;
$$ LANGUAGE plpgsql;
CREATE TABLE scrolls (
id INTEGER PRIMARY KEY,
wizard_id INTEGER REFERENCES wizards(id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(2);
expect(result.warnings).toBeDefined();
expect(result.warnings!.some((w) => w.includes('Function'))).toBe(
true
);
});
it('should skip and warn about triggers', async () => {
const sql = `
CREATE TABLE spell_audit_log (id SERIAL PRIMARY KEY);
CREATE TRIGGER spell_audit_trigger
AFTER INSERT ON spell_audit_log
FOR EACH ROW
EXECUTE FUNCTION spell_audit_function();
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.warnings).toBeDefined();
expect(result.warnings!.some((w) => w.includes('Trigger'))).toBe(
true
);
});
it('should skip and warn about policies', async () => {
const sql = `
CREATE TABLE arcane_secrets (id INTEGER PRIMARY KEY);
CREATE POLICY wizard_policy ON arcane_secrets
FOR SELECT
TO public
USING (true);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.warnings).toBeDefined();
expect(result.warnings!.some((w) => w.includes('Policy'))).toBe(
true
);
});
it('should skip and warn about RLS', async () => {
const sql = `
CREATE TABLE enchanted_vault (id INTEGER PRIMARY KEY);
ALTER TABLE enchanted_vault ENABLE ROW LEVEL SECURITY;
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.warnings).toBeDefined();
expect(
result.warnings!.some((w) =>
w.toLowerCase().includes('row level security')
)
).toBe(true);
});
});
describe('Edge Cases', () => {
it('should handle tables after failed function parsing', async () => {
const sql = `
CREATE TABLE before_enchantment (id INTEGER PRIMARY KEY);
CREATE FUNCTION complex_spell()
RETURNS TABLE(id INTEGER, name TEXT) AS $$
BEGIN
RETURN QUERY SELECT 1, 'test';
END;
$$ LANGUAGE plpgsql;
CREATE TABLE after_enchantment (
id INTEGER PRIMARY KEY,
ref_id INTEGER REFERENCES before_enchantment(id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(2);
expect(result.tables.map((t) => t.name).sort()).toEqual([
'after_enchantment',
'before_enchantment',
]);
expect(result.relationships).toHaveLength(1);
});
it('should handle empty or null input', async () => {
const result1 = await fromPostgres('');
expect(result1.tables).toHaveLength(0);
expect(result1.relationships).toHaveLength(0);
const result2 = await fromPostgres(' \n ');
expect(result2.tables).toHaveLength(0);
expect(result2.relationships).toHaveLength(0);
});
it('should handle comments in various positions', async () => {
const sql = `
-- This is a comment
CREATE TABLE /* inline comment */ wizards (
id INTEGER PRIMARY KEY, -- end of line comment
/* multi-line
comment */
name TEXT
);
-- Another comment
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.tables[0].name).toBe('wizards');
expect(result.tables[0].columns).toHaveLength(2);
});
it('should handle dollar-quoted strings', async () => {
const sql = `
CREATE TABLE spell_messages (
id INTEGER PRIMARY KEY,
template TEXT DEFAULT $tag$Hello, 'world'!$tag$,
content TEXT
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(1);
expect(result.tables[0].columns).toHaveLength(3);
});
});
describe('Foreign Key Extraction from Unparsed Tables', () => {
it('should extract foreign keys from tables that fail to parse', async () => {
const sql = `
CREATE TABLE ancient_artifact (id UUID PRIMARY KEY);
-- This table has syntax that might fail parsing
CREATE TABLE mystical_formula (
id UUID PRIMARY KEY,
artifact_ref UUID REFERENCES ancient_artifact(id),
value NUMERIC(10,
2) GENERATED ALWAYS AS (1 + 1) STORED,
FOREIGN KEY (artifact_ref) REFERENCES ancient_artifact(id) ON DELETE CASCADE
);
CREATE TABLE enchanted_relic (
id UUID PRIMARY KEY,
formula_ref UUID REFERENCES mystical_formula(id)
);
`;
const result = await fromPostgres(sql);
expect(result.tables).toHaveLength(3);
// Should find foreign keys even if mystical_formula fails to parse
expect(result.relationships.length).toBeGreaterThanOrEqual(2);
});
});
});

View File

@@ -0,0 +1,199 @@
import { describe, it, expect } from 'vitest';
import { fromPostgres } from '../postgresql';
describe('PostgreSQL Parser Regression Tests', () => {
it('should parse all 16 tables from the magical academy example', async () => {
// This is a regression test for the issue where 3 tables were missing
const sql = `
-- Core tables
CREATE TABLE magic_schools(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE towers(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES magic_schools(id) ON DELETE CASCADE,
name text NOT NULL
);
CREATE TABLE wizards(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES magic_schools(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
wizard_name text NOT NULL,
magic_email text NOT NULL,
UNIQUE (school_id, wizard_name)
);
-- This function should not prevent the wizards table from being parsed
CREATE FUNCTION enforce_wizard_tower_school()
RETURNS TRIGGER AS $$
BEGIN
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TABLE wizard_ranks(
wizard_id uuid NOT NULL REFERENCES wizards(id) ON DELETE CASCADE,
rank_id uuid NOT NULL REFERENCES magical_ranks(id) ON DELETE CASCADE,
tower_id uuid NOT NULL REFERENCES towers(id) ON DELETE CASCADE,
PRIMARY KEY (wizard_id, rank_id, tower_id)
);
-- Another function that should be skipped
CREATE FUNCTION another_function() RETURNS void AS $$
BEGIN
-- Do nothing
END;
$$ LANGUAGE plpgsql;
CREATE TABLE magical_ranks(
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
school_id uuid NOT NULL REFERENCES magic_schools(id) ON DELETE CASCADE,
name text NOT NULL
);
-- Row level security should not break parsing
ALTER TABLE wizards ENABLE ROW LEVEL SECURITY;
CREATE TABLE spell_logs(
id bigserial PRIMARY KEY,
school_id uuid,
wizard_id uuid,
action text NOT NULL
);
`;
const result = await fromPostgres(sql);
// Should find all 6 tables
expect(result.tables).toHaveLength(6);
const tableNames = result.tables.map((t) => t.name).sort();
expect(tableNames).toEqual([
'magic_schools',
'magical_ranks',
'spell_logs',
'towers',
'wizard_ranks',
'wizards',
]);
if (result.warnings) {
expect(result.warnings.length).toBeGreaterThan(0);
expect(
result.warnings.some(
(w) => w.includes('Function') || w.includes('security')
)
).toBe(true);
} else {
expect(result.tables).toHaveLength(6);
}
});
it('should handle tables with complex syntax that fail parsing', async () => {
const sql = `
CREATE TABLE simple_table (
id uuid PRIMARY KEY,
name text NOT NULL
);
-- This table has complex syntax that might fail parsing
CREATE TABLE complex_table (
id uuid PRIMARY KEY,
value numeric(10,
2), -- Multi-line numeric
computed numeric(5,2) GENERATED ALWAYS AS (value * 2) STORED,
UNIQUE (id, value)
);
CREATE TABLE another_table (
id uuid PRIMARY KEY,
complex_id uuid REFERENCES complex_table(id),
simple_id uuid REFERENCES simple_table(id)
);
`;
const result = await fromPostgres(sql);
// Should find all 3 tables even if complex_table fails to parse
expect(result.tables).toHaveLength(3);
expect(result.tables.map((t) => t.name).sort()).toEqual([
'another_table',
'complex_table',
'simple_table',
]);
// Should extract foreign keys even from unparsed tables
const fksFromAnother = result.relationships.filter(
(r) => r.sourceTable === 'another_table'
);
expect(fksFromAnother).toHaveLength(2);
expect(
fksFromAnother.some((fk) => fk.targetTable === 'complex_table')
).toBe(true);
expect(
fksFromAnother.some((fk) => fk.targetTable === 'simple_table')
).toBe(true);
});
it('should count relationships correctly for multi-tenant system', async () => {
// Simplified version focusing on relationship counting
const sql = `
CREATE TABLE tenants(id uuid PRIMARY KEY);
CREATE TABLE branches(
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id)
);
CREATE TABLE roles(
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id)
);
CREATE TABLE permissions(id uuid PRIMARY KEY);
CREATE TABLE role_permissions(
role_id uuid NOT NULL REFERENCES roles(id),
permission_id uuid NOT NULL REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE record_types(
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id)
);
CREATE TABLE users(
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id),
branch_id uuid NOT NULL REFERENCES branches(id)
);
CREATE TABLE user_roles(
user_id uuid NOT NULL REFERENCES users(id),
role_id uuid NOT NULL REFERENCES roles(id),
branch_id uuid NOT NULL REFERENCES branches(id),
PRIMARY KEY (user_id, role_id, branch_id)
);
CREATE TABLE patients(
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id),
branch_id uuid NOT NULL REFERENCES branches(id),
primary_physician uuid REFERENCES users(id),
referring_physician uuid REFERENCES users(id)
);
`;
const result = await fromPostgres(sql);
// Count expected relationships:
// branches: 1 (tenant_id -> tenants)
// roles: 1 (tenant_id -> tenants)
// role_permissions: 2 (role_id -> roles, permission_id -> permissions)
// record_types: 1 (tenant_id -> tenants)
// users: 2 (tenant_id -> tenants, branch_id -> branches)
// user_roles: 3 (user_id -> users, role_id -> roles, branch_id -> branches)
// patients: 4 (tenant_id -> tenants, branch_id -> branches, primary_physician -> users, referring_physician -> users)
// Total: 14
expect(result.relationships).toHaveLength(14);
});
});

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