From d0cd2d8fa4ce14b8bfdd3fc82feb57d2e864c597 Mon Sep 17 00:00:00 2001 From: Alberto Arena Date: Wed, 5 Aug 2026 06:24:18 +0200 Subject: [PATCH 1/4] 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. --- package-lock.json | 395 ++++++++++++++++++++++++- package.json | 7 +- src/utils/field.test.ts | 60 ++++ src/utils/render/render-uttils.test.ts | 68 +++++ src/utils/tables.test.ts | 67 +++++ tsconfig.node.json | 2 +- vitest.config.ts | 13 + 7 files changed, 608 insertions(+), 4 deletions(-) create mode 100644 src/utils/field.test.ts create mode 100644 src/utils/render/render-uttils.test.ts create mode 100644 src/utils/tables.test.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 86ad547..0b1ea76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,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": { @@ -7635,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", @@ -7931,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", @@ -8108,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", @@ -8189,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", @@ -8206,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", @@ -8628,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", @@ -9022,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", @@ -9783,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", @@ -9799,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", @@ -11407,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", @@ -11897,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", @@ -12911,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", @@ -12952,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", @@ -13236,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", @@ -13708,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", @@ -14165,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", @@ -14285,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", diff --git a/package.json b/package.json index b3ff9af..5ecdd34 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "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", @@ -105,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" } } diff --git a/src/utils/field.test.ts b/src/utils/field.test.ts new file mode 100644 index 0000000..5be7851 --- /dev/null +++ b/src/utils/field.test.ts @@ -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 => + ({ + 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"); + }); +}); diff --git a/src/utils/render/render-uttils.test.ts b/src/utils/render/render-uttils.test.ts new file mode 100644 index 0000000..92b52d5 --- /dev/null +++ b/src/utils/render/render-uttils.test.ts @@ -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 __enum", () => { + const table = { name: "Order" } as TableType; + const field = { name: "Status" } as FieldType; + + expect(getPostgresEnumName(table, field)).toBe("Order_status_enum"); + }); +}); diff --git a/src/utils/tables.test.ts b/src/utils/tables.test.ts new file mode 100644 index 0000000..ae4ed36 --- /dev/null +++ b/src/utils/tables.test.ts @@ -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"); + }); +}); diff --git a/tsconfig.node.json b/tsconfig.node.json index aee816c..3415122 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -8,5 +8,5 @@ "strict": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1dbf5b2 --- /dev/null +++ b/vitest.config.ts @@ -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"], + }, +}); From 585f5c7a7ffa9964aa2dc0458fa8b909bc33a3b0 Mon Sep 17 00:00:00 2001 From: Alberto Arena Date: Wed, 5 Aug 2026 06:30:49 +0200 Subject: [PATCH 2/4] 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. --- src/test/fixtures/data-types.ts | 54 ++++++ src/utils/import/import.test.ts | 294 ++++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 src/test/fixtures/data-types.ts create mode 100644 src/utils/import/import.test.ts diff --git a/src/test/fixtures/data-types.ts b/src/test/fixtures/data-types.ts new file mode 100644 index 0000000..cfb81df --- /dev/null +++ b/src/test/fixtures/data-types.ts @@ -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[], + 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.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]; diff --git a/src/utils/import/import.test.ts b/src/utils/import/import.test.ts new file mode 100644 index 0000000..75c3b13 --- /dev/null +++ b/src/utils/import/import.test.ts @@ -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(); + }); +}); From 91fe5d6cf2d44c3e13f09275cb4c722ca7b77afc Mon Sep 17 00:00:00 2001 From: Alberto Arena Date: Wed, 5 Aug 2026 07:26:31 +0200 Subject: [PATCH 3/4] 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. --- src/test/fixtures/database.ts | 178 ++++++++++++++++++++++++++++++++ src/utils/render/render.test.ts | 111 ++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 src/test/fixtures/database.ts create mode 100644 src/utils/render/render.test.ts diff --git a/src/test/fixtures/database.ts b/src/test/fixtures/database.ts new file mode 100644 index 0000000..3c20046 --- /dev/null +++ b/src/test/fixtures/database.ts @@ -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 => + ({ + 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; +}; diff --git a/src/utils/render/render.test.ts b/src/utils/render/render.test.ts new file mode 100644 index 0000000..1d1280f --- /dev/null +++ b/src/utils/render/render.test.ts @@ -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 => + 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); + }); + }); + } +}); From 42e2bcf5e976db006236d382614a7e469bdfffe8 Mon Sep 17 00:00:00 2001 From: Alberto Arena Date: Wed, 5 Aug 2026 08:13:50 +0200 Subject: [PATCH 4/4] 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. --- .github/workflows/ci.yml | 18 +++ src/utils/round-trip.test.ts | 244 +++++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 src/utils/round-trip.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d73026 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/src/utils/round-trip.test.ts b/src/utils/round-trip.test.ts new file mode 100644 index 0000000..fb217ff --- /dev/null +++ b/src/utils/round-trip.test.ts @@ -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["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)); + }); + } +});