14 Commits

Author SHA1 Message Date
KarimTamani e169b84ade Add UUID V4 to PostgreSQL 2026-08-05 23:42:16 +01:00
Tamani Karim 994c797195 Merge pull request #8 from albertoarena/test/automated-tests
test: add automated test suite (Vitest) for the SQL import/render pipeline
2026-08-05 23:01:43 +01:00
Alberto Arena 42e2bcf5e9 test: add round-trip tests and CI workflow
Add the Phase 4 import -> render -> import round-trip: for each of the six
dialects, parse a CREATE TABLE + foreign key schema into a model, render it
back to DDL, parse the rendered DDL again, and assert the two models are
equal. Comparison is on a normalized model (table and column names, resolved
type names, key/constraint flags, relationships), not raw SQL, since
formatting and identifier quoting legitimately differ. A small in-memory
adapter assembles the DatabaseType the renderer consumes from the importer
output, which the app normally reconstructs via the database.

Primary keys are canonicalized to non-nullable in the comparison: SQL Server
emits its primary key as a table-level constraint and the importer only
forces NOT NULL for inline primary keys, so the flag would otherwise differ
on the round trip although the schemas are equivalent.

Add a GitHub Actions workflow running npm ci + npm test on pushes to main
and on pull requests. Typecheck and lint are intentionally left out for now:
the current codebase does not pass a strict project typecheck or a clean
lint, so gating on them would fail CI on pre-existing, unrelated issues.
2026-08-05 08:13:50 +02:00
Alberto Arena 91fe5d6cf2 test: add Phase 3 SQL render tests for all dialects
Cover getRenderer().renderDDL across MySQL, MariaDB, PostgreSQL, SQLite,
Oracle and SQL Server. A shared DatabaseType fixture (users + posts with a
primary key, auto-increment, NOT NULL UNIQUE column with a length, DEFAULT
and a posts to users foreign key) is rendered per dialect, asserting the
emitted DDL creates both tables and columns and carries the primary key,
unique constraint, dialect-specific auto-increment spelling (AUTO_INCREMENT,
SERIAL, AUTOINCREMENT, IDENTITY), variable-length text type, DEFAULT value
and the foreign key with ON DELETE CASCADE.

The fixture is built from the seed data types so field type ids hydrate the
way the app hydrates them, and embeds relationship source/target objects
because the SQLite renderer orders tables from the raw database before the
migration step re-hydrates. Assertions match quote-agnostic patterns rather
than exact strings, since identifier quoting and formatting differ by dialect.
2026-08-05 07:26:31 +02:00
Alberto Arena 585f5c7a7f test: add Phase 2 SQL import tests for all dialects
Cover getImporter().parseSql across MySQL, MariaDB, PostgreSQL, SQLite,
Oracle and SQL Server. Each dialect gets a representative CREATE TABLE +
foreign key + index fixture, asserting table and column counts, primary
key, NOT NULL, UNIQUE and DEFAULT parsing, data-type mapping, and the
captured foreign key (direction, cardinality and ON DELETE action). Also
parse the bundled PostgreSQL dump as a realistic case and verify malformed
input surfaces errors without dropping valid tables or throwing.

Data-type fixtures are built from the seed arrays via the same transform
the app uses in seedDataTypes, so tests run without booting the WASM
SQLite database. The parser is a WASM module whose init() is async; the
importer constructor does not await it, so the suite awaits init() once in
beforeAll, after which every synchronous parseSql call resolves.
2026-08-05 06:30:49 +02:00
Alberto Arena d0cd2d8fa4 test: add Vitest and Phase 1 pure-helper suite
Introduce automated testing to a project that had no test runner, no
test script and no CI. Vitest fits the existing Vite setup with near-zero
config; a standalone vitest.config.ts wires the @/ alias via
vite-tsconfig-paths and runs in the node environment, avoiding the app's
Tailwind/React/WASM plugins that pure-logic tests do not need.

Cover the cheapest, highest-value surface first: pure helpers with no DOM,
WASM or database. getNextSequence and cloneField (field.ts), the
charset/collation and SQLite integer-column reordering plus enum naming
(render-uttils.ts), and orderTables including the CircularDependencyError
cycle path that backs the Foreign Key Cycle Detection feature.

The build's tsc step is unaffected (plain tsc no-ops on the root config)
and the new files typecheck clean under strict and lint clean.
2026-08-05 06:24:18 +02:00
Tamani Karim fab51f2d13 Merge pull request #7 from albertoarena/build/build-oom-heap-flag
PR approved!

Thank you, @albertoarena , for your contribution! We really appreciate your support.
2026-08-04 17:46:11 +01:00
Alberto Arena fe84e0d748 build: raise Node heap for production build
The production build renders thousands of modules and exceeds V8's
default heap ceiling, so `npm run build` fails with a JavaScript
heap out-of-memory error during "rendering chunks". Set
NODE_OPTIONS=--max-old-space-size=8192 on the Vite build step via
cross-env (kept portable for Windows), so a fresh clone builds
without any manual environment setup. Document the matching Docker
memory requirement in DOCKER.md, since the container build hits the
same limit when the Docker VM has too little RAM.
2026-08-04 18:06:11 +02:00
Tamani Karim ae9e052cf6 Merge pull request #6 from albertoarena/docs/fix-readme-docker-docs
docs: fix README typos and Docker Compose syntax
2026-08-04 16:30:10 +01:00
Alberto Arena 557387a4fd docs: fix README typos and Docker Compose syntax
Correct the "Welcome" heading typo and reword "Index Suggestions"
in the feature list. The intro now lists all six exportable SQL
dialects (adding Oracle and SQL Server) so it matches the
Supported Databases section. Add the port 3000 dev-server URL to
the local setup instructions, and switch the Docker Compose
commands in README and DOCKER.md from the deprecated V1
"docker-compose" to the current V2 "docker compose" syntax.
2026-08-04 17:25:52 +02:00
KarimTamani 4a2f6ae8f5 add COOP/COEP headers to nginx config to fix macOS database crashes 2026-06-30 16:12:15 +01:00
KarimTamani e8838d1f1e Update the README image 2026-06-10 22:34:07 +01:00
Tamani Karim c1ae2dbf69 New Dialects 2026-06-10 22:16:08 +01:00
KarimTamani eb6c80417c 1.4.0 2026-06-10 22:12:23 +01:00
26 changed files with 1636 additions and 44 deletions
+18
View File
@@ -0,0 +1,18 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
+11 -3
View File
@@ -9,7 +9,7 @@ This guide explains how to run StackRender using Docker.
The easiest way to run StackRender:
```bash
docker-compose up -d
docker compose up -d
```
The application will be available at `http://localhost:8080`
@@ -17,7 +17,7 @@ The application will be available at `http://localhost:8080`
To stop the application:
```bash
docker-compose down
docker compose down
```
### Using Docker CLI
@@ -103,6 +103,14 @@ RUN npm config set strict-ssl false && npm install
This is necessary in some build environments with certificate validation issues.
### Out of Memory During Build
The production build is memory-intensive (the app compiles thousands of modules). If `docker compose up` or `docker build` fails with an out-of-memory error such as `Reached heap limit` or `cannot allocate memory` during the `npm run build` step, increase the memory available to Docker:
- **Docker Desktop**: Settings, then Resources, then Memory. Set it to at least **6-8 GB** and apply.
The Dockerfile raises the Node heap (`NODE_OPTIONS=--max-old-space-size`) for the build. That heap size must fit inside the memory available to Docker, so raising the Docker memory limit is the fix when the build runs out of memory.
### Container Not Starting
Check the logs:
@@ -114,7 +122,7 @@ docker logs stackrender
Or with Docker Compose:
```bash
docker-compose logs
docker compose logs
```
### Port Already in Use
+9 -6
View File
@@ -1,6 +1,6 @@
![App Screenshot](https://github.com/stackrender/.github/blob/main/assets/white_hero.PNG?raw=true)
![App Screenshot](https://github.com/stackrender/.github/blob/main/assets/app_screenshot.png?raw=true)
<h4 align="center">
<a href="https://www.stackrender.io">
<img src="https://img.shields.io/badge/Start%20Building!-gray.svg?logo=data:image/svg+xml;base64,PHN2ZyBmaWxsPSIjRkZENzAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMiIgaGVpZ2h0PSIxMiIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTMgMyBMMiAxMyBoNyBMMTEgMjEgTDIyIDExIGgtNyBMIDEzIDMgeiIvPjwvc3ZnPg==" alt="Start Building!" />
@@ -15,16 +15,16 @@
</h4>
# Welcom to StackRender ⚡
# Welcome to StackRender ⚡
StackRender was born from the need to automate backend development, covering everything from the database to the final API endpoint. Our first step toward this long-term vision is to provide a **next-generation, free, open-source database schema diagram generator**.
StackRender helps you go from **specifications** to a **fully functional, production-ready database** that can be exported in your preferred SQL dialect: **MySQL, PostgreSQL, MariaDB, or SQLite**.
StackRender helps you go from **specifications** to a **fully functional, production-ready database** that can be exported in your preferred SQL dialect: **MySQL, PostgreSQL, MariaDB, SQLite, Oracle, or SQL Server**.
## Main features
- **Interactive Diagram UI** Visually design and manage your database schemas with an intuitive drag-and-drop interface.
- **In-Depth Tables & Columns Control** Fully customize tables, columns, types, and constraints.
- **Indices Suggestions** Receive recommendations to optimize database performance.
- **Index Suggestions** Receive recommendations to optimize database performance.
- **Import / Export SQL DDL** Easily import existing schemas or export your design as SQL scripts.
- **Foreign Key Cycle Detection** Identify and resolve circular dependencies in relationships.
- **AI-Powered Database Assistant** (Cloud version) Generate database diagrams from specifications and perform additional operations such as schema enrichment, soft-delete implementation, and automatic documentation generation.
@@ -39,8 +39,9 @@ StackRender is currently in **Public Beta**. Star and watch this repository to g
- ✅ MySQL
- ✅ MariaDB
- ✅ SQLite
- ✅ Oracle
- ✅ SQL Server (MSSQL)
And more coming very soon!
## Get Started
Use the [cloud version](https://www.stackrender.io) or deploy locally to start designing your database schemas in minutes.
@@ -52,7 +53,7 @@ The easiest way to run StackRender locally is using Docker:
```bash
# Build and run using Docker Compose
docker-compose up
docker compose up
# Or build and run using Docker directly
docker build -t stackrender .
@@ -68,6 +69,8 @@ npm install
npm run dev
```
Then visit `http://localhost:3000` in your browser.
### How to Build
Install dependencies and create a production build:
```bash
+7
View File
@@ -15,6 +15,11 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Putting these at the server level ensures BOTH index.html AND your cached .js/.wasm assets use them!
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
@@ -24,5 +29,7 @@ server {
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|wasm)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
}
}
+416 -3
View File
@@ -1,12 +1,12 @@
{
"name": "StackRender",
"version": "1.3.3",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "StackRender",
"version": "1.3.3",
"version": "1.4.0",
"dependencies": {
"@codemirror/lang-sql": "^6.9.0",
"@dnd-kit/core": "^6.3.1",
@@ -86,6 +86,7 @@
"@typescript-eslint/parser": "8.11.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "10.4.19",
"cross-env": "^7.0.3",
"esbuild": "^0.28.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "9.1.0",
@@ -102,7 +103,8 @@
"typescript": "5.6.3",
"vite": "^5.2.0",
"vite-plugin-top-level-await": "^1.5.0",
"vite-tsconfig-paths": "^4.3.2"
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^2.1.9"
}
},
"node_modules/@adobe/react-spectrum": {
@@ -7634,6 +7636,119 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "2.1.9",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^3.0.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xyflow/react": {
"version": "12.11.0",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz",
@@ -7930,6 +8045,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -8107,6 +8232,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -8188,6 +8323,23 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -8205,6 +8357,16 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -8363,6 +8525,25 @@
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -8608,6 +8789,16 @@
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -9002,6 +9193,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -9763,6 +9961,16 @@
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -9779,6 +9987,16 @@
"integrity": "sha512-KGft0ldl31BZVV//jj+IAIGCxkvvUkkON+ScH6zfoX+l+omX6001ggyRSpI0Io2Hlro0ThXotswCtfzS8UkIiQ==",
"license": "MIT"
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -11387,6 +11605,13 @@
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -11877,6 +12102,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/pgsql-ast-parser": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/pgsql-ast-parser/-/pgsql-ast-parser-12.0.2.tgz",
@@ -12891,6 +13133,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
@@ -12932,6 +13181,20 @@
"sql-formatter": "bin/sql-formatter-cli.cjs"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -13216,6 +13479,50 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -13688,6 +13995,29 @@
}
}
},
"node_modules/vite-node": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-plugin-top-level-await": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz",
@@ -14145,6 +14475,72 @@
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@@ -14265,6 +14661,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+8 -4
View File
@@ -1,13 +1,15 @@
{
"name": "StackRender",
"private": true,
"version": "1.3.3",
"version": "1.4.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build": "tsc && cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build",
"lint": "eslint -c .eslintrc.json ./src/**/**/*.{ts,tsx} --fix",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@codemirror/lang-sql": "^6.9.0",
@@ -88,6 +90,7 @@
"@typescript-eslint/parser": "8.11.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "10.4.19",
"cross-env": "^7.0.3",
"esbuild": "^0.28.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "9.1.0",
@@ -104,6 +107,7 @@
"typescript": "5.6.3",
"vite": "^5.2.0",
"vite-plugin-top-level-await": "^1.5.0",
"vite-tsconfig-paths": "^4.3.2"
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^2.1.9"
}
}
@@ -7,7 +7,7 @@ import { DataTypes, TimeDefaultValues } from "@/lib/field";
import { useDatabaseOperations } from "@/providers/database-provider/database-provider";
import dayjs from 'dayjs';
import { now } from "@internationalized/date";
import { now } from "@internationalized/date";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
@@ -16,6 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { MultiSelect } from "@/components/multi-select";
import { DatePicker } from "@/components/date-picker";
import { DatabaseDialect } from "@/lib/database";
interface DefaultValueType {
number?: boolean;
@@ -23,7 +24,8 @@ interface DefaultValueType {
boolean?: boolean;
time?: boolean;
select?: boolean;
multiSelect?: boolean
multiSelect?: boolean;
uuid?: boolean
}
interface FieldDefaultValueProps {
@@ -64,8 +66,8 @@ const fieldDefautlValue: React.FC<FieldDefaultValueProps> = ({ field }) => {
const [defaultDateTime, setDefaultDateTime] = useState<any>(() => {
try {
if (field.type.name == "time" ) {
return field.defaultValue ;
if (field.type.name == "time") {
return field.defaultValue;
}
if (field.defaultValue) {
@@ -86,7 +88,8 @@ const fieldDefautlValue: React.FC<FieldDefaultValueProps> = ({ field }) => {
boolean: field.type?.type == DataTypes.BOOLEAN,
time: field.type?.type == DataTypes.TIME && field.type?.name != "year",
select: field.type?.type == DataTypes.ENUM && field.type?.name != "set",
multiSelect: field.type?.type == DataTypes.ENUM && field.type?.name == "set"
multiSelect: field.type?.type == DataTypes.ENUM && field.type?.name == "set",
uuid: field.type.name == "uuid" || field.type.name == "uniqueidentifier"
}
}, [field]);
@@ -94,7 +97,7 @@ const fieldDefautlValue: React.FC<FieldDefaultValueProps> = ({ field }) => {
const [selectedValues, setSelectedValues] = useState<string[] | string>(() => {
if (defaultValueType.select) {
if (defaultValueType.select || defaultValueType.uuid) {
if (!field.defaultValue)
return "none";
else
@@ -214,8 +217,8 @@ const fieldDefautlValue: React.FC<FieldDefaultValueProps> = ({ field }) => {
else if (field.type.name == "datetime" || field.type.name == "timestamp" || field.type.name == "timestamptz")
value = dayjs(date).format("YYYY-MM-DD HH:mm:ss")
}
else if ( field.type.name == "time") {
value = defaultDateTime ;
else if (field.type.name == "time") {
value = defaultDateTime;
}
@@ -227,7 +230,7 @@ const fieldDefautlValue: React.FC<FieldDefaultValueProps> = ({ field }) => {
const enumValueChange = useCallback((selection: any) => {
if (defaultValueType.select) {
if (defaultValueType.select || defaultValueType.uuid) {
setSelectedValues(selection);
editField({
id: field.id,
@@ -256,11 +259,11 @@ const fieldDefautlValue: React.FC<FieldDefaultValueProps> = ({ field }) => {
return (
<>
{
!defaultValueType.boolean &&
<Label htmlFor="default_value">
{t("db_controller.field_settings.default_value")}
</Label>
@@ -317,7 +320,7 @@ const fieldDefautlValue: React.FC<FieldDefaultValueProps> = ({ field }) => {
<SelectContent>
<SelectItem value={"none"} >
No Default value
{t("db_controller.field_settings.no_default")}
</SelectItem>
{
@@ -333,6 +336,34 @@ const fieldDefautlValue: React.FC<FieldDefaultValueProps> = ({ field }) => {
</SelectContent>
</Select>
}
{
(defaultValueType.uuid) &&
<Select
aria-label="value"
value={selectedValues as any}
onValueChange={enumValueChange as any}
>
<SelectTrigger id="charset" className="w-full flex ">
<SelectValue placeholder={t('db_controller.field_settings.pick_value')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={"none"} >
{t("db_controller.field_settings.no_default")}
</SelectItem>
<SelectItem value={"random"} >
{t("db_controller.field_settings.random_uuid")}
</SelectItem>
</SelectContent>
</Select>
}
{
(defaultValueType.multiSelect) &&
<MultiSelect
@@ -364,7 +395,7 @@ const fieldDefautlValue: React.FC<FieldDefaultValueProps> = ({ field }) => {
</SelectItem>
<SelectItem value={TimeDefaultValues.CUSTOM}>{t("db_controller.field_settings.time_default_value.custom")}</SelectItem>
{
(field.type.name == "datetime" || field.type.name?.includes("timestamp")) ?
(field.type.name?.includes("datetime") || field.type.name?.includes("timestamp") || ((field.type.dialect == DatabaseDialect.ORACLE || field.type.dialect == DatabaseDialect.MSSQL) && field.type.name == "date")) ?
<SelectItem value={TimeDefaultValues.NOW}>{t("db_controller.field_settings.time_default_value.now")}</SelectItem> : null
}
</SelectContent>
@@ -391,10 +422,9 @@ const fieldDefautlValue: React.FC<FieldDefaultValueProps> = ({ field }) => {
type="time"
step={1}
defaultValue={defaultDateTime}
onChange={(event) => setDefaultDateTime( event.target.value)}
onChange={(event) => setDefaultDateTime(event.target.value)}
onBlur={saveDefaultDateTime as any}
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</>
}
@@ -411,3 +441,5 @@ export default React.memo(fieldDefautlValue);
+2
View File
@@ -95,6 +95,8 @@ export const ar = {
type_enter: "اكتب واضغط Enter",
precision_def: "إجمالي الأرقام المسموحة (قبل + بعد الفاصلة).",
scale_def: "عدد الأرقام بعد الفاصلة.",
no_default: "لا توجد قيمة افتراضية",
random_uuid: "معرّف UUID عشوائي",
time_default_value: {
no_value: "لا توجد قيمة",
custom: "وقت مخصص",
+2
View File
@@ -95,6 +95,8 @@ export const de = {
type_enter: "Tippen und Enter drücken",
precision_def: "Zulässige Gesamtanzahl an Ziffern (vor + nach dem Dezimalpunkt).",
scale_def: "Ziffern nach dem Dezimalpunkt.",
no_default: "Kein Standardwert",
random_uuid: "Zufällige UUID",
time_default_value: {
no_value: "Kein Wert",
custom: "Benutzerdefinierte Zeit",
+4 -2
View File
@@ -8,7 +8,7 @@ export const en = {
tables: "Tables",
relationships: "Relationships",
database: "Database",
documentation : "Docs"
documentation: "Docs"
},
color_picker: {
@@ -104,6 +104,8 @@ export const en = {
type_enter: "Type and press enter",
precision_def: "Total digits allowed (before + after the decimal).",
scale_def: "Digits allowed after the decimal.",
no_default: "No Default value",
random_uuid: "Random UUID",
time_default_value: {
no_value: "No value",
custom: "Custom time",
@@ -209,7 +211,7 @@ export const en = {
import_database: {
title: "Import your Database",
import: "Import",
view_docs : "View Docs" ,
view_docs: "View Docs",
import_options: "Would you like to import using :",
import_error: "SQL Parsing Error",
import_error_description: "We couldn't import your SQL because it contains invalid syntax.",
+2
View File
@@ -99,6 +99,8 @@ export const es = {
type_enter: "Escribe y presiona enter",
precision_def: "Total de dígitos permitidos (antes + después del decimal).",
scale_def: "Dígitos permitidos después del decimal.",
no_default: "Sin valor predeterminado",
random_uuid: "UUID aleatorio",
time_default_value: {
no_value: "Sin valor",
custom: "Hora personalizada",
+7 -5
View File
@@ -7,7 +7,7 @@ export const fr = {
tables: "Tables",
relationships: "Relations",
database: "Base de données",
documentation : "Docs"
documentation: "Docs"
},
color_picker: {
@@ -33,7 +33,7 @@ export const fr = {
note: "Note",
name: "Nom",
type: "Type",
required: "Obligatoire",
required: "Obligatoire",
nullable: "Nullable",
select_fields: "Sélectionner les champs",
@@ -97,6 +97,8 @@ export const fr = {
type_enter: "Tapez et appuyez sur Entrée",
precision_def: "Nombre total de chiffres autorisés (avant + après la virgule).",
scale_def: "Nombre de chiffres autorisés après la virgule.",
no_default: "Aucune valeur par défaut",
random_uuid: "UUID aléatoire",
time_default_value: {
no_value: "Aucune valeur",
custom: "Heure personnalisée",
@@ -173,7 +175,7 @@ export const fr = {
theme: "Thème",
light: "Clair",
dark: "Sombre",
system: "Système",
system: "Système",
help: "Aide",
show_docs: "Afficher la documentation",
@@ -199,7 +201,7 @@ export const fr = {
import_database: {
title: "Importer votre base de données",
import: "Importer",
view_docs : "Voir la documentation" ,
view_docs: "Voir la documentation",
import_options: "Souhaitez-vous importer en utilisant :",
import_error: "Erreur danalyse SQL",
import_error_description: "Nous n'avons pas pu importer votre SQL car il contient une syntaxe invalide.",
@@ -263,7 +265,7 @@ export const fr = {
step4: "Dans la fenêtre, sélectionnez <bold>Exporter uniquement le schéma</bold> et cliquez sur <bold>Enregistrer</bold>.",
step5: "Enfin, copiez le contenu du fichier <code>.sql</code> dans l’éditeur de code ci-dessous."
},
ssms: {
ssms: {
"step1": "Ouvrez SQL Server Management Studio (SSMS).",
"step2": "Faites un clic droit sur votre base de données, puis sélectionnez Tâches → Générer des scripts dans le menu contextuel.",
"step3": "Dans l’étape Choisir les objets, sélectionnez Choisir des objets spécifiques de la base de données, puis cochez toutes les tables.",
+2
View File
@@ -100,6 +100,8 @@ export const hi = {
type_enter: "टाइप करें और एंटर दबाएं",
precision_def: "कुल अनुमत अंक (दशमलव से पहले और बाद)।",
scale_def: "दशमलव के बाद अनुमत अंक।",
no_default: "कोई डिफ़ॉल्ट मान नहीं",
random_uuid: "रैंडम UUID",
time_default_value: {
no_value: "कोई मान नहीं",
custom: "कस्टम समय",
+2
View File
@@ -101,6 +101,8 @@ export const pt = {
type_enter: "Digite e pressione enter",
precision_def: "Dígitos totais permitidos (antes + depois do decimal).",
scale_def: "Dígitos permitidos após o decimal.",
no_default: "Sem valor padrão",
random_uuid: "UUID aleatório",
time_default_value: {
no_value: "Sem valor",
custom: "Hora personalizada",
+2
View File
@@ -93,6 +93,8 @@ export const ru = {
type_enter: "Введите и нажмите Enter",
precision_def: "Общее количество допустимых цифр (до и после запятой).",
scale_def: "Допустимое количество цифр после запятой.",
no_default: "Нет значения по умолчанию",
random_uuid: "Случайный UUID",
time_default_value: {
no_value: "Нет значения",
custom: "Произвольное время",
+2
View File
@@ -101,6 +101,8 @@ export const zh = {
type_enter: "输入并按回车",
precision_def: "允许的总位数(小数点前+后)。",
scale_def: "小数点后的位数。",
no_default: "无默认值",
random_uuid: "随机 UUID",
time_default_value: {
no_value: "无值",
custom: "自定义时间",
+54
View File
@@ -0,0 +1,54 @@
import { DatabaseDialect } from "@/lib/database";
import { DataInsertType, DataType } from "@/lib/schemas/data-type-schema";
import { MysqlDataType } from "@/lib/data_types/mysql_data_types";
import { PostgresDataType } from "@/lib/data_types/postgres_data_types";
import { SqliteDataTypes } from "@/lib/data_types/sqlite_data_types";
import { MariaDbDataType } from "@/lib/data_types/mariadb_data_types";
import { OracleDataType } from "@/lib/data_types/oracle_data_types";
import { MSSQLDataType } from "@/lib/data_types/mssql_data_types";
// Build a DataType[] fixture straight from the seed arrays, applying the exact
// same transform as seedDataTypes() -> mapToDataType() in the app (see
// src/lib/data_types/seed_datatypes.ts). The importer reads `modifiers` and
// `synonyms` as JSON strings, so they must be stringified here just like the
// production seeding does. This lets the tests exercise the real supported-type
// tables without booting the WASM SQLite database.
const mapToDataType = (
dataTypes: Partial<DataInsertType>[],
dialect: DatabaseDialect,
): DataType[] =>
dataTypes.map(
(dataType) =>
({
...dataType,
dialect,
modifiers: dataType.modifiers
? JSON.stringify(dataType.modifiers)
: null,
synonyms: dataType.synonyms ? JSON.stringify(dataType.synonyms) : null,
}) as DataType,
);
const dataTypesByDialect: Record<DatabaseDialect, DataType[]> = {
[DatabaseDialect.MYSQL]: mapToDataType(MysqlDataType, DatabaseDialect.MYSQL),
[DatabaseDialect.POSTGRES]: mapToDataType(
PostgresDataType,
DatabaseDialect.POSTGRES,
),
[DatabaseDialect.SQLITE]: mapToDataType(
SqliteDataTypes,
DatabaseDialect.SQLITE,
),
[DatabaseDialect.MARIADB]: mapToDataType(
MariaDbDataType,
DatabaseDialect.MARIADB,
),
[DatabaseDialect.ORACLE]: mapToDataType(
OracleDataType,
DatabaseDialect.ORACLE,
),
[DatabaseDialect.MSSQL]: mapToDataType(MSSQLDataType, DatabaseDialect.MSSQL),
};
export const getDataTypes = (dialect: DatabaseDialect): DataType[] =>
dataTypesByDialect[dialect];
+178
View File
@@ -0,0 +1,178 @@
import { getDataTypes } from "./data-types";
import { DatabaseDialect } from "@/lib/database";
import { ForeignKeyActions } from "@/lib/field";
import { DataType } from "@/lib/schemas/data-type-schema";
import { DatabaseType } from "@/lib/schemas/database-schema";
import { FieldType } from "@/lib/schemas/field-schema";
import { IndexType } from "@/lib/schemas/index-schema";
import { TableType } from "@/lib/schemas/table-schema";
import {
Cardinality,
RelationshipType,
} from "@/lib/schemas/relationship-schema";
// Pick a data type by preferred name, falling back to a category predicate.
// The seed arrays list types in dialect-specific order, so selecting by
// category alone would grab e.g. CHAR before VARCHAR or TINYINT before INTEGER;
// naming the canonical type keeps the rendered DDL representative per dialect.
const pick = (
types: DataType[],
names: string[],
fallback: (t: DataType) => boolean,
): DataType => {
for (const name of names) {
const match = types.find((t) => t.name?.toLowerCase() === name);
if (match) return match;
}
return types.find(fallback) as DataType;
};
// Resolve a representative type per category for a dialect, straight from the
// seed data types, so the fixture uses real type ids the renderer can hydrate.
const resolveTypes = (dialect: DatabaseDialect) => {
const types = getDataTypes(dialect);
const integer = pick(
types,
["integer", "int", "number"],
(t) => t.type === "integer" || t.type === "numeric",
);
const varchar = pick(
types,
["varchar", "varchar2", "nvarchar", "character varying"],
(t) => t.type === "text",
);
return { integer, varchar };
};
const field = (over: Partial<FieldType>): FieldType =>
({
isPrimary: false,
nullable: true,
unique: false,
autoIncrement: false,
sequence: 0,
...over,
}) as FieldType;
/**
* A minimal but representative two-table schema (users, posts) with a
* primary key, an auto-increment column, a NOT NULL UNIQUE text column with a
* length, a DEFAULT, and a posts -> users foreign key. Ids are stable strings
* so assertions and failures are readable. Relationship source/target objects
* are left for prepareForMigration/optimizeOps to hydrate from the ids, exactly
* as the app does before rendering.
*/
export const buildSampleDatabase = (dialect: DatabaseDialect): DatabaseType => {
const { integer, varchar } = resolveTypes(dialect);
const usersFields: FieldType[] = [
field({
id: "users.id",
tableId: "users",
name: "id",
typeId: integer.id,
isPrimary: true,
nullable: false,
autoIncrement: true,
}),
field({
id: "users.email",
tableId: "users",
name: "email",
typeId: varchar.id,
nullable: false,
unique: true,
maxLength: 255,
}),
field({
id: "users.status",
tableId: "users",
name: "status",
typeId: varchar.id,
nullable: false,
maxLength: 20,
defaultValue: "active",
}),
];
const postsFields: FieldType[] = [
field({
id: "posts.id",
tableId: "posts",
name: "id",
typeId: integer.id,
isPrimary: true,
nullable: false,
autoIncrement: true,
}),
field({
id: "posts.user_id",
tableId: "posts",
name: "user_id",
typeId: integer.id,
nullable: false,
}),
field({
id: "posts.title",
tableId: "posts",
name: "title",
typeId: varchar.id,
nullable: false,
maxLength: 255,
}),
];
const users = {
id: "users",
name: "users",
fields: usersFields,
indices: [] as IndexType[],
sequence: 0,
} as TableType;
const posts = {
id: "posts",
name: "posts",
fields: postsFields,
indices: [] as IndexType[],
sequence: 1,
} as TableType;
const tables: TableType[] = [users, posts];
// Embed the source/target table and field objects the way the app's data
// layer hands them to the renderer. The SQLite renderer orders tables up
// front by reading relationship.sourceTable / targetTable off the original
// database (before prepareForMigration re-hydrates), so these must be present.
const relationships: RelationshipType[] = [
{
id: "rel_posts_users",
name: null,
sourceTableId: "users",
targetTableId: "posts",
sourceFieldId: "users.id",
targetFieldId: "posts.user_id",
sourceTable: users,
targetTable: posts,
sourceField: usersFields[0],
targetField: postsFields[1],
cardinality: Cardinality.one_to_many,
onDelete: ForeignKeyActions.CASCADE,
databaseId: "db",
} as RelationshipType,
];
return {
id: "db",
name: "testdb",
dialect,
numOfTables: tables.length,
createdAt: null,
tables,
relationships,
} as DatabaseType;
};
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { cloneField, getNextSequence } from "./field";
import { FieldType } from "@/lib/schemas/field-schema";
// Minimal field factory. FieldType is a Drizzle-inferred row type with many
// columns; these helpers only read `sequence` (and copy the rest), so a partial
// cast keeps the fixtures focused on what is under test.
const makeField = (overrides: Partial<FieldType> = {}): FieldType =>
({
id: "f1",
tableId: "t1",
name: "col",
sequence: 0,
...overrides,
}) as FieldType;
describe("getNextSequence", () => {
it("returns 0 for an empty field list", () => {
expect(getNextSequence([])).toBe(0);
});
it("returns max sequence + 1", () => {
const fields = [
makeField({ sequence: 0 }),
makeField({ sequence: 5 }),
makeField({ sequence: 2 }),
];
expect(getNextSequence(fields)).toBe(6);
});
it("handles a single field", () => {
expect(getNextSequence([makeField({ sequence: 4 })])).toBe(5);
});
});
describe("cloneField", () => {
it("assigns a fresh id and preserves other properties", () => {
const original = makeField({
id: "original-id",
name: "email",
sequence: 3,
});
const clone = cloneField(original);
expect(clone.id).not.toBe(original.id);
expect(clone.name).toBe("email");
expect(clone.sequence).toBe(3);
expect(clone.tableId).toBe(original.tableId);
});
it("does not mutate the source field", () => {
const original = makeField({ id: "original-id" });
cloneField(original);
expect(original.id).toBe("original-id");
});
});
+294
View File
@@ -0,0 +1,294 @@
import { describe, it, expect, beforeAll } from "vitest";
import { init } from "@guanmingchiu/sqlparser-ts";
import { DatabaseDialect } from "@/lib/database";
import { getImporter } from "@/utils/import/import-utils";
import { getDataTypes } from "@/test/fixtures/data-types";
import { PostgresSqlExample } from "@/lib/import/import_db";
import { TableInsertType } from "@/lib/schemas/table-schema";
import { FieldInsertType } from "@/lib/schemas/field-schema";
// The parser is a WASM module whose init() is async. BaseSqlImporter's
// constructor kicks it off but does not await it (fine in the browser, where
// the promise resolves long before a user imports SQL). In tests we must await
// it once up front; init() caches the module globally, so every subsequent
// synchronous parseSql() call across all dialects then works.
beforeAll(async () => {
await init();
});
const getField = (table: TableInsertType, name: string): FieldInsertType =>
(table.fields ?? []).find((f) => f.name === name) as FieldInsertType;
interface DialectCase {
name: string;
dialect: DatabaseDialect;
sql: string;
// MySQL / MariaDB / SQL Server surface AUTO_INCREMENT / IDENTITY; the others do not.
idAutoIncrement: boolean;
// SQLite uses TEXT (no length); the rest carry a VARCHAR length.
varcharMaxLength: number | undefined;
// Only the MySQL and PostgreSQL fixtures include a CREATE INDEX statement.
indexCount: number;
// Only the fixtures that spell out ON DELETE CASCADE carry an onDelete action.
fkOnDelete: string | undefined;
}
const cases: DialectCase[] = [
{
name: "MySQL",
dialect: DatabaseDialect.MYSQL,
idAutoIncrement: true,
varcharMaxLength: 255,
indexCount: 1,
fkOnDelete: "cascade",
sql: `
CREATE TABLE users (
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'active'
);
CREATE TABLE posts (
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX idx_posts_user_id ON posts (user_id);
`,
},
{
name: "MariaDB",
dialect: DatabaseDialect.MARIADB,
idAutoIncrement: true,
varcharMaxLength: 255,
indexCount: 0,
fkOnDelete: "cascade",
sql: `
CREATE TABLE users (
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'active'
);
CREATE TABLE posts (
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`,
},
{
name: "PostgreSQL",
dialect: DatabaseDialect.POSTGRES,
idAutoIncrement: false,
varcharMaxLength: 255,
indexCount: 1,
fkOnDelete: "cascade",
sql: `
CREATE TABLE users (
id integer PRIMARY KEY,
email varchar(255) NOT NULL UNIQUE,
status varchar(20) NOT NULL DEFAULT 'active'
);
CREATE TABLE posts (
id integer PRIMARY KEY,
user_id integer NOT NULL,
title varchar(255) NOT NULL,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX idx_posts_user_id ON posts (user_id);
`,
},
{
name: "SQLite",
dialect: DatabaseDialect.SQLITE,
idAutoIncrement: false,
varcharMaxLength: undefined,
indexCount: 0,
fkOnDelete: "cascade",
sql: `
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`,
},
{
name: "Oracle",
dialect: DatabaseDialect.ORACLE,
idAutoIncrement: false,
varcharMaxLength: 255,
indexCount: 0,
fkOnDelete: undefined,
sql: `
CREATE TABLE users (
id NUMBER PRIMARY KEY,
email VARCHAR2(255) NOT NULL UNIQUE,
status VARCHAR2(20) DEFAULT 'active' NOT NULL
);
CREATE TABLE posts (
id NUMBER PRIMARY KEY,
user_id NUMBER NOT NULL,
title VARCHAR2(255) NOT NULL,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id)
);
`,
},
{
name: "SQL Server",
dialect: DatabaseDialect.MSSQL,
idAutoIncrement: true,
varcharMaxLength: 255,
indexCount: 0,
fkOnDelete: undefined,
sql: `
CREATE TABLE users (
id INT IDENTITY(1,1) PRIMARY KEY,
email NVARCHAR(255) NOT NULL UNIQUE,
status NVARCHAR(20) NOT NULL DEFAULT 'active'
);
CREATE TABLE posts (
id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT NOT NULL,
title NVARCHAR(255) NOT NULL,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id)
);
`,
},
];
describe("getImporter().parseSql - CREATE TABLE across dialects", () => {
for (const c of cases) {
describe(c.name, () => {
const parse = () =>
getImporter(c.dialect, getDataTypes(c.dialect)).parseSql(c.sql);
const typeById = new Map(
getDataTypes(c.dialect).map((dt) => [dt.id, dt]),
);
it("parses both tables with their columns and no errors", () => {
const r = parse();
expect(r.errors).toHaveLength(0);
expect(r.tables.map((t) => t.name).sort()).toEqual(["posts", "users"]);
const users = r.tables.find(
(t) => t.name === "users",
) as TableInsertType;
const posts = r.tables.find(
(t) => t.name === "posts",
) as TableInsertType;
expect(users.fields).toHaveLength(3);
expect(posts.fields).toHaveLength(3);
});
it("maps the primary key column", () => {
const users = parse().tables.find(
(t) => t.name === "users",
) as TableInsertType;
const id = getField(users, "id");
expect(id.isPrimary).toBe(true);
expect(id.nullable).toBe(false);
expect(id.autoIncrement).toBe(c.idAutoIncrement);
// id resolves to a numeric family type (integer, or numeric for Oracle NUMBER)
expect(["integer", "numeric"]).toContain(
typeById.get(id.typeId as string)?.type,
);
});
it("maps a NOT NULL UNIQUE text column with its length", () => {
const users = parse().tables.find(
(t) => t.name === "users",
) as TableInsertType;
const email = getField(users, "email");
expect(email.unique).toBe(true);
expect(email.nullable).toBe(false);
expect(email.isPrimary).toBe(false);
expect(typeById.get(email.typeId as string)?.type).toBe("text");
expect(email.maxLength ?? undefined).toBe(c.varcharMaxLength);
});
it("parses the DEFAULT value", () => {
const users = parse().tables.find(
(t) => t.name === "users",
) as TableInsertType;
const status = getField(users, "status");
expect(status.defaultValue).toBe("active");
expect(status.nullable).toBe(false);
});
it("captures the foreign key from posts to users", () => {
const r = parse();
const byName = new Map(r.tables.map((t) => [t.name, t.id]));
expect(r.relationships).toHaveLength(1);
const rel = r.relationships[0];
// convention: source is the referenced (parent) table, target holds the FK
expect(rel.sourceTableId).toBe(byName.get("users"));
expect(rel.targetTableId).toBe(byName.get("posts"));
expect(rel.cardinality).toBe("one_to_many");
expect(rel.onDelete).toBe(c.fkOnDelete);
});
it("parses CREATE INDEX statements", () => {
const r = parse();
expect(r.indexes).toHaveLength(c.indexCount);
if (c.indexCount > 0) {
const posts = r.tables.find(
(t) => t.name === "posts",
) as TableInsertType;
expect(r.indexes[0].name).toBe("idx_posts_user_id");
expect(r.indexes[0].tableId).toBe(posts.id);
}
});
});
}
});
describe("getImporter().parseSql - realistic and malformed input", () => {
it("parses the bundled PostgreSQL dump", () => {
const r = getImporter(
DatabaseDialect.POSTGRES,
getDataTypes(DatabaseDialect.POSTGRES),
).parseSql(PostgresSqlExample);
expect(r.tables.length).toBe(4);
expect(r.relationships.length).toBe(3);
expect(r.errors).toHaveLength(0);
});
it("collects errors from a malformed statement without dropping valid tables", () => {
const r = getImporter(
DatabaseDialect.POSTGRES,
getDataTypes(DatabaseDialect.POSTGRES),
).parseSql(
"CREATE TABLE good (id integer PRIMARY KEY); CREATE TABLE bad (;",
);
expect(r.tables.map((t) => t.name)).toContain("good");
expect(r.errors.length).toBeGreaterThan(0);
});
it("throws when there is nothing parseable", () => {
const importer = getImporter(
DatabaseDialect.POSTGRES,
getDataTypes(DatabaseDialect.POSTGRES),
);
expect(() => importer.parseSql("this is not sql at all")).toThrow();
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import {
fixCharsetPlacement,
fixSQLiteColumnOrder,
getPostgresEnumName,
} from "./render-uttils";
import { FieldType } from "@/lib/schemas/field-schema";
import { TableType } from "@/lib/schemas/table-schema";
describe("fixCharsetPlacement", () => {
it("moves CHARACTER SET / COLLATE directly after the column type", () => {
const sql = [
"CREATE TABLE users (",
"name VARCHAR(255) NOT NULL CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci,",
"email TEXT",
")",
].join("\n");
const out = fixCharsetPlacement(sql);
expect(out).toContain(
"name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL",
);
// charset/collation must sit before the remaining attributes
const line = out.split("\n").find((l) => l.includes("CHARACTER SET")) ?? "";
expect(line.indexOf("CHARACTER SET")).toBeLessThan(
line.indexOf("NOT NULL"),
);
});
it("leaves columns without charset/collation untouched", () => {
const sql = ["CREATE TABLE t (", "email TEXT", ")"].join("\n");
expect(fixCharsetPlacement(sql)).toContain("email TEXT");
});
});
describe("fixSQLiteColumnOrder", () => {
it("reorders INTEGER PK attributes to PRIMARY KEY / AUTOINCREMENT / NOT NULL", () => {
const sql = [
"CREATE TABLE t (",
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT",
")",
].join("\n");
expect(fixSQLiteColumnOrder(sql)).toContain(
"id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL",
);
});
it("leaves non-primary-key INTEGER columns unchanged", () => {
const sql = ["CREATE TABLE t (", "age INTEGER DEFAULT 0", ")"].join("\n");
expect(fixSQLiteColumnOrder(sql)).toContain("age INTEGER DEFAULT 0");
});
});
describe("getPostgresEnumName", () => {
it("builds <table>_<lowercased field>_enum", () => {
const table = { name: "Order" } as TableType;
const field = { name: "Status" } as FieldType;
expect(getPostgresEnumName(table, field)).toBe("Order_status_enum");
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect } from "vitest";
import { DatabaseDialect } from "@/lib/database";
import { getRenderer } from "@/utils/render/render-uttils";
import { getDataTypes } from "@/test/fixtures/data-types";
import { buildSampleDatabase } from "@/test/fixtures/database";
// getRenderer().renderDDL() takes a DatabaseType and emits dialect DDL. It runs
// the migration diff (empty database -> the fixture), so the output is the full
// CREATE for every table plus the foreign key. Assertions match on substrings
// and quote-agnostic patterns rather than exact strings, since formatting and
// identifier quoting differ per dialect and are not what these tests pin down.
interface RenderCase {
name: string;
dialect: DatabaseDialect;
// Dialect-specific spelling of an auto-increment / identity column.
autoIncrement: RegExp;
// Dialect-specific spelling of a variable-length string type.
varchar: RegExp;
}
const cases: RenderCase[] = [
{
name: "MySQL",
dialect: DatabaseDialect.MYSQL,
autoIncrement: /AUTO_INCREMENT/,
varchar: /VARCHAR\s*\(\s*255\s*\)/i,
},
{
name: "MariaDB",
dialect: DatabaseDialect.MARIADB,
autoIncrement: /AUTO_INCREMENT/,
varchar: /VARCHAR\s*\(\s*255\s*\)/i,
},
{
name: "PostgreSQL",
dialect: DatabaseDialect.POSTGRES,
// auto-increment integers become SERIAL in Postgres
autoIncrement: /SERIAL/,
varchar: /VARCHAR\s*\(\s*255\s*\)/i,
},
{
name: "SQLite",
dialect: DatabaseDialect.SQLITE,
autoIncrement: /AUTOINCREMENT/,
// SQLite has no VARCHAR; the text column renders as TEXT
varchar: /TEXT\s*\(\s*255\s*\)/i,
},
{
name: "Oracle",
dialect: DatabaseDialect.ORACLE,
autoIncrement: /IDENTITY/,
varchar: /VARCHAR2\s*\(\s*255\s*\)/i,
},
{
name: "SQL Server",
dialect: DatabaseDialect.MSSQL,
autoIncrement: /IDENTITY/,
varchar: /VARCHAR\s*\(\s*255\s*\)/i,
},
];
const render = (dialect: DatabaseDialect): Promise<string> =>
getRenderer(dialect, getDataTypes(dialect))!.renderDDL(
buildSampleDatabase(dialect),
);
describe("getRenderer().renderDDL - emitted DDL across dialects", () => {
for (const c of cases) {
describe(c.name, () => {
it("creates both tables with their columns", async () => {
const sql = await render(c.dialect);
expect(sql).toMatch(/CREATE TABLE\s+[`"]?users[`"]?/i);
expect(sql).toMatch(/CREATE TABLE\s+[`"]?posts[`"]?/i);
for (const col of ["id", "email", "status", "user_id", "title"]) {
expect(sql).toContain(col);
}
});
it("emits primary key, unique and auto-increment", async () => {
const sql = await render(c.dialect);
expect(sql).toMatch(/PRIMARY KEY/i);
expect(sql).toMatch(/UNIQUE/i);
expect(sql).toMatch(c.autoIncrement);
});
it("emits the text column type with its length", async () => {
const sql = await render(c.dialect);
expect(sql).toMatch(c.varchar);
});
it("emits the DEFAULT value", async () => {
const sql = await render(c.dialect);
expect(sql).toMatch(/DEFAULT\s+'active'/i);
});
it("emits the foreign key from posts to users with ON DELETE CASCADE", async () => {
const sql = await render(c.dialect);
expect(sql).toMatch(/FOREIGN KEY\s*\(\s*user_id\s*\)/i);
expect(sql).toMatch(/REFERENCES\s+[`"]?users[`"]?\s*\(\s*id\s*\)/i);
expect(sql).toMatch(/ON DELETE CASCADE/i);
});
});
}
});
+244
View File
@@ -0,0 +1,244 @@
import { describe, it, expect, beforeAll } from "vitest";
import { init } from "@guanmingchiu/sqlparser-ts";
import { DatabaseDialect } from "@/lib/database";
import { getImporter } from "@/utils/import/import-utils";
import { getRenderer } from "@/utils/render/render-uttils";
import { getDataTypes } from "@/test/fixtures/data-types";
import { DatabaseType } from "@/lib/schemas/database-schema";
import { TableType } from "@/lib/schemas/table-schema";
import { IndexType } from "@/lib/schemas/index-schema";
import { RelationshipType } from "@/lib/schemas/relationship-schema";
import { FieldType } from "@/lib/schemas/field-schema";
// Round-trip: parse DDL -> model -> render DDL -> parse again, and assert the
// two models are equal. This is the strongest integration check of the
// import/render pipeline. We compare a normalized model (names, resolved type
// names, key/constraint flags, relationships), not raw SQL, since formatting
// and identifier quoting legitimately differ.
type ParseResult = ReturnType<ReturnType<typeof getImporter>["parseSql"]>;
// Adapter: assemble a DatabaseType (what renderDDL consumes) from parseSql
// output (tables + id-based relationships). The app normally round-trips this
// through the SQLite database; here we build it in memory. Relationship
// source/target objects are embedded because the SQLite renderer reads them off
// the raw database when ordering tables.
const toDatabase = (
dialect: DatabaseDialect,
result: ParseResult,
): DatabaseType => {
const tables = result.tables.map(
(t) => ({ ...t, indices: [] as IndexType[] }) as TableType,
);
const table = (id: string) => tables.find((t) => t.id === id);
const relationships = result.relationships.map((r) => {
const source = table(r.sourceTableId);
const target = table(r.targetTableId);
return {
...r,
databaseId: "db",
sourceTable: source,
targetTable: target,
sourceField: source?.fields?.find((f) => f.id === r.sourceFieldId),
targetField: target?.fields?.find((f) => f.id === r.targetFieldId),
} as RelationshipType;
});
return {
id: "db",
name: "roundtrip",
dialect,
numOfTables: tables.length,
createdAt: null,
tables,
relationships,
} as DatabaseType;
};
const byName = (a: { name?: string | null }, b: { name?: string | null }) =>
(a.name ?? "").localeCompare(b.name ?? "");
const normalize = (dialect: DatabaseDialect, result: ParseResult) => {
const types = getDataTypes(dialect);
const typeName = (id?: string | null) =>
types.find((t) => t.id === id)?.name ?? null;
const tableName = (id: string) =>
result.tables.find((t) => t.id === id)?.name ?? id;
const fieldName = (tableId: string, fieldId: string) =>
result.tables
.find((t) => t.id === tableId)
?.fields?.find((f: FieldType) => f.id === fieldId)?.name ?? fieldId;
return {
tables: [...result.tables].sort(byName).map((t) => ({
name: t.name,
columns: [...(t.fields ?? [])].sort(byName).map((f: FieldType) => ({
name: f.name,
type: typeName(f.typeId),
isPrimary: !!f.isPrimary,
// a primary key is non-nullable in every SQL dialect; canonicalize it.
// (SQL Server renders the PK as a table constraint, and the importer
// only forces NOT NULL for inline primary keys, so without this the
// round-tripped nullable flag would spuriously differ for MSSQL.)
nullable: f.isPrimary ? false : !!f.nullable,
unique: !!f.unique,
autoIncrement: !!f.autoIncrement,
maxLength: f.maxLength ?? null,
defaultValue: f.defaultValue ?? null,
})),
})),
relationships: result.relationships
.map((r) => ({
source: `${tableName(r.sourceTableId)}.${fieldName(
r.sourceTableId,
r.sourceFieldId,
)}`,
target: `${tableName(r.targetTableId)}.${fieldName(
r.targetTableId,
r.targetFieldId,
)}`,
cardinality: r.cardinality,
onDelete: r.onDelete ?? null,
}))
.sort((a, b) => (a.source + a.target).localeCompare(b.source + b.target)),
};
};
interface RoundTripCase {
name: string;
dialect: DatabaseDialect;
sql: string;
}
const cases: RoundTripCase[] = [
{
name: "MySQL",
dialect: DatabaseDialect.MYSQL,
sql: `
CREATE TABLE users (
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'active'
);
CREATE TABLE posts (
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);`,
},
{
name: "MariaDB",
dialect: DatabaseDialect.MARIADB,
sql: `
CREATE TABLE users (
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'active'
);
CREATE TABLE posts (
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);`,
},
{
name: "PostgreSQL",
dialect: DatabaseDialect.POSTGRES,
sql: `
CREATE TABLE users (
id integer PRIMARY KEY,
email varchar(255) NOT NULL UNIQUE,
status varchar(20) NOT NULL DEFAULT 'active'
);
CREATE TABLE posts (
id integer PRIMARY KEY,
user_id integer NOT NULL,
title varchar(255) NOT NULL,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);`,
},
{
name: "SQLite",
dialect: DatabaseDialect.SQLITE,
sql: `
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);`,
},
{
name: "Oracle",
dialect: DatabaseDialect.ORACLE,
sql: `
CREATE TABLE users (
id NUMBER PRIMARY KEY,
email VARCHAR2(255) NOT NULL UNIQUE,
status VARCHAR2(20) DEFAULT 'active' NOT NULL
);
CREATE TABLE posts (
id NUMBER PRIMARY KEY,
user_id NUMBER NOT NULL,
title VARCHAR2(255) NOT NULL,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id)
);`,
},
{
name: "SQL Server",
dialect: DatabaseDialect.MSSQL,
sql: `
CREATE TABLE users (
id INT IDENTITY(1,1) PRIMARY KEY,
email NVARCHAR(255) NOT NULL UNIQUE,
status NVARCHAR(20) NOT NULL DEFAULT 'active'
);
CREATE TABLE posts (
id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT NOT NULL,
title NVARCHAR(255) NOT NULL,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id)
);`,
},
];
describe("import -> render -> import round-trip", () => {
beforeAll(async () => {
await init();
});
for (const c of cases) {
it(`${c.name} model is stable`, async () => {
const first = getImporter(c.dialect, getDataTypes(c.dialect)).parseSql(
c.sql,
);
// sanity: the seed actually produced the model we intend to round-trip
expect(first.errors).toHaveLength(0);
expect(first.tables).toHaveLength(2);
expect(first.relationships).toHaveLength(1);
const rendered = await getRenderer(
c.dialect,
getDataTypes(c.dialect),
)!.renderDDL(toDatabase(c.dialect, first));
const second = getImporter(c.dialect, getDataTypes(c.dialect)).parseSql(
rendered,
);
expect(second.errors).toHaveLength(0);
expect(normalize(c.dialect, second)).toEqual(normalize(c.dialect, first));
});
}
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { orderTables } from "./tables";
import { SortableTable } from "@/lib/table";
// `relationships` holds the ids of the tables this table depends on (its foreign
// keys / parents), so a valid topological order lists dependencies first.
describe("orderTables", () => {
it("orders a dependency chain parent-first", () => {
const tables: SortableTable[] = [
{ tableId: "C", relationships: ["B"] },
{ tableId: "A", relationships: [] },
{ tableId: "B", relationships: ["A"] },
];
const order = orderTables(tables);
expect(order).toHaveLength(3);
expect(order.indexOf("A")).toBeLessThan(order.indexOf("B"));
expect(order.indexOf("B")).toBeLessThan(order.indexOf("C"));
});
it("places a table after every dependency it references", () => {
const tables: SortableTable[] = [
{ tableId: "orders", relationships: ["users", "products"] },
{ tableId: "users", relationships: [] },
{ tableId: "products", relationships: [] },
];
const order = orderTables(tables);
expect(order.indexOf("users")).toBeLessThan(order.indexOf("orders"));
expect(order.indexOf("products")).toBeLessThan(order.indexOf("orders"));
});
it("throws a CircularDependencyError describing the cycle", () => {
const tables: SortableTable[] = [
{ tableId: "A", relationships: ["B"] },
{ tableId: "B", relationships: ["A"] },
];
let caught: unknown;
try {
orderTables(tables);
} catch (error) {
caught = error;
}
expect(caught).toBeDefined();
const err = caught as {
success: boolean;
message: string;
cycle: string[];
};
expect(err.success).toBe(false);
expect(err.message).toBe("Cycle detected");
expect(Array.isArray(err.cycle)).toBe(true);
// the cycle is reported as a closed loop (first node repeated at the end)
expect(err.cycle[0]).toBe(err.cycle[err.cycle.length - 1]);
expect(err.cycle).toContain("A");
expect(err.cycle).toContain("B");
});
});
+1 -1
View File
@@ -8,5 +8,5 @@
"strict": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "vitest.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
// Standalone test config: the app's vite.config.ts pulls in Tailwind, React and
// WASM/worker plugins that pure-logic tests do not need. We only need the `@/`
// alias to resolve, which vite-tsconfig-paths provides from tsconfig.json.
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});