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/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/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/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/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/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(); + }); +}); 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/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); + }); + }); + } +}); 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)); + }); + } +}); 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"], + }, +});