diff --git a/package-lock.json b/package-lock.json index ed218abe3..a8c3ffcaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,8 @@ "postcss": "^8.5.23", "prettier": "3.2.5", "tailwindcss": "^4.0.14", - "vite": "^8.1.0" + "vite": "^8.1.0", + "vitest": "^4.1.11" } }, "node_modules/@alloc/quick-lru": { @@ -1171,6 +1172,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -1527,6 +1535,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1536,6 +1555,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1709,6 +1735,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1939,6 +2078,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/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -2140,6 +2289,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2287,6 +2446,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -2740,6 +2906,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3167,6 +3340,16 @@ "node": ">=0.10.0" } }, + "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/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4926,9 +5109,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.18", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", - "integrity": "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6203,6 +6386,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6374,6 +6571,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -7371,6 +7575,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/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -7400,6 +7611,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -7416,6 +7634,13 @@ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "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", @@ -7709,6 +7934,23 @@ "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": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -7726,6 +7968,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -8393,6 +8645,96 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -8514,6 +8856,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 848104764..21fe12ce8 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@dagrejs/dagre": "^3.1.1", @@ -58,6 +59,7 @@ "postcss": "^8.5.23", "prettier": "3.2.5", "tailwindcss": "^4.0.14", - "vite": "^8.1.0" + "vite": "^8.1.0", + "vitest": "^4.1.11" } } diff --git a/src/components/EditorHeader/Modal/Modal.jsx b/src/components/EditorHeader/Modal/Modal.jsx index 149549d3a..7a1cdf2ef 100644 --- a/src/components/EditorHeader/Modal/Modal.jsx +++ b/src/components/EditorHeader/Modal/Modal.jsx @@ -27,6 +27,7 @@ import { import { isRtl } from "../../../i18n/utils/rtl"; import { useExtensions } from "../../../context/ExtensionsContext"; import { importSQL } from "../../../utils/importSQL"; +import { parseMssqlSource } from "../../../utils/importSQL/mssql"; import { allowedTypesFor, normalizeAiDiagram, @@ -128,6 +129,8 @@ export default function Modal({ const oracleParser = new OracleParser(); ast = oracleParser.parse(importSource.src); + } else if (targetDatabase === DB.MSSQL) { + ast = parseMssqlSource(importSource.src); } else { const parser = new Parser(); diff --git a/src/utils/importSQL/mssql.js b/src/utils/importSQL/mssql.js index e9aea86db..f30eeb156 100644 --- a/src/utils/importSQL/mssql.js +++ b/src/utils/importSQL/mssql.js @@ -1,3 +1,4 @@ +import { Parser } from "node-sql-parser"; import { nanoid } from "nanoid"; import { Cardinality, DB } from "../../data/constants"; import { dbToTypes } from "../../data/datatypes"; @@ -318,3 +319,120 @@ export function fromMSSQL(ast, diagramDb = DB.GENERIC) { return { tables, relationships }; } + +const GO_BATCH_SEPARATOR = /^[ \t]*GO[ \t]*(--[^\n]*)?$/gim; +const GO_MARKER = "\0"; +const ALTER_FK_STATEMENT = new RegExp( + [ + "ALTER\\s+TABLE\\s+", + "(?:[\\[\\w\\].]+\\.)?", // optional schema prefix + "((?:\\[[^\\]]+\\])|[\\w$]+)", // table + "\\s+ADD\\s+", + "(?:CONSTRAINT\\s+((?:\\[[^\\]]+\\])|[\\w$]+)\\s+)?", + "FOREIGN\\s+KEY\\s*\\(\\s*([^)]+?)\\s*\\)", + "\\s+REFERENCES\\s+", + "(?:[\\[\\w\\].]+\\.)?", + "((?:\\[[^\\]]+\\])|[\\w$]+)", + "\\s*\\(\\s*([^)]+?)\\s*\\)", + "([^;]*?)", + "(?:;(?:\\s*GO\\b)?|\\s*GO\\b|\\s*$)", + ].join(""), + "gi", +); + +function unquoteIdentifier(identifier) { + return identifier.startsWith("[") + ? identifier.slice(1, -1).replace(/\]\]/g, "]") + : identifier; +} + +function quoteIdentifier(identifier) { + return "`" + identifier.replace(/`/g, "``") + "`"; +} + +function parseColumns(columns) { + return columns + .split(",") + .map((column) => column.trim()) + .filter(Boolean) + .map(unquoteIdentifier); +} + +function parseReferentialAction(actions) { + const onUpdate = + /on\s+update\s+(cascade|no\s+action|restrict|set\s+null|set\s+default)/i.exec( + actions, + ); + const onDelete = + /on\s+delete\s+(cascade|no\s+action|restrict|set\s+null|set\s+default)/i.exec( + actions, + ); + + let action = ""; + if (onUpdate) { + action += ` ON UPDATE ${onUpdate[1].replace(/\s+/g, " ").toUpperCase()}`; + } + if (onDelete) { + action += ` ON DELETE ${onDelete[1].replace(/\s+/g, " ").toUpperCase()}`; + } + return action; +} + +function toMysqlAlterForeignKey([ + table, + constraintName, + startColumns, + endTable, + endColumns, + actions, +]) { + const startColumnList = parseColumns(startColumns) + .map(quoteIdentifier) + .join(", "); + const endColumnList = parseColumns(endColumns) + .map(quoteIdentifier) + .join(", "); + const constraint = constraintName + ? `CONSTRAINT ${quoteIdentifier(unquoteIdentifier(constraintName))} ` + : ""; + + return ( + `ALTER TABLE ${quoteIdentifier(unquoteIdentifier(table))} ` + + `ADD ${constraint}FOREIGN KEY (${startColumnList}) ` + + `REFERENCES ${quoteIdentifier(unquoteIdentifier(endTable))} (${endColumnList})` + + parseReferentialAction(actions) + ); +} + +/** + * Parses a transactsql script into a list of statements. + * + * The transactsql grammar of node-sql-parser cannot parse `ALTER TABLE ... ADD + * FOREIGN KEY` statements and fails when a semicolon-terminated statement is + * followed by a GO batch separator, which breaks importing scripts that + * drawdb itself exported (issue #320). GO batches are therefore parsed as + * semicolon-separated statements and foreign keys are parsed with the mysql + * grammar, which produces the alter statement shape fromMSSQL already + * understands. + */ +export function parseMssqlSource(source) { + const foreignKeys = []; + const statementsSource = source + .replace(ALTER_FK_STATEMENT, (match, ...groups) => { + foreignKeys.push(toMysqlAlterForeignKey(groups)); + return ""; + }) + .replace(GO_BATCH_SEPARATOR, GO_MARKER) + .replace(/;[ \t\r\n]*\0|\0[ \t\r\n]*;/g, GO_MARKER) + .replace(/\0/g, ";"); + + const parser = new Parser(); + const parsed = parser.astify(statementsSource, { database: DB.MSSQL }); + const statements = Array.isArray(parsed) ? parsed : [parsed]; + + for (const foreignKey of foreignKeys) { + statements.push(parser.astify(foreignKey, { database: DB.MYSQL })); + } + + return statements; +} diff --git a/tests/importSQL.mssql.test.js b/tests/importSQL.mssql.test.js new file mode 100644 index 000000000..1a6fc3a07 --- /dev/null +++ b/tests/importSQL.mssql.test.js @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vitest"; +import { toMSSQL } from "../src/utils/exportSQL/mssql"; +import { importSQL } from "../src/utils/importSQL"; +import { parseMssqlSource } from "../src/utils/importSQL/mssql"; +import { DB } from "../src/data/constants"; + +const diagram = { + database: DB.MSSQL, + tables: [ + { + id: "t1", + name: "users", + comment: "", + fields: [ + { + id: "f1", + name: "id", + type: "INT", + size: "", + increment: true, + notNull: true, + unique: false, + primary: true, + default: "", + check: "", + comment: "", + }, + { + id: "f2", + name: "email", + type: "NVARCHAR", + size: "255", + increment: false, + notNull: true, + unique: false, + primary: false, + default: "", + check: "", + comment: "", + }, + ], + indices: [], + uniqueConstraints: [], + }, + { + id: "t2", + name: "posts", + comment: "", + fields: [ + { + id: "f3", + name: "id", + type: "BIGINT", + size: "", + increment: true, + notNull: true, + unique: false, + primary: true, + default: "", + check: "", + comment: "", + }, + { + id: "f4", + name: "user_id", + type: "INT", + size: "", + increment: false, + notNull: true, + unique: false, + primary: false, + default: "", + check: "", + comment: "", + }, + ], + indices: [ + { name: "idx_posts_user_id", unique: false, fields: ["user_id"] }, + ], + uniqueConstraints: [], + }, + ], + references: [ + { + id: "r1", + name: "fk_posts_user_id_users", + startTableId: "t2", + endTableId: "t1", + startFieldId: "f4", + endFieldId: "f1", + fields: [{ startFieldId: "f4", endFieldId: "f1" }], + cardinality: "many_to_one", + updateConstraint: "No action", + deleteConstraint: "Cascade", + }, + ], +}; + +describe("MSSQL export/import round-trip (#320)", () => { + it("imports a diagram previously exported with toMSSQL", () => { + const sql = toMSSQL(diagram); + const ast = parseMssqlSource(sql); + const imported = importSQL(ast, DB.MSSQL, DB.MSSQL); + + expect(imported.tables.map((t) => t.name)).toEqual(["users", "posts"]); + expect(imported.tables[0].fields.map((f) => f.name)).toEqual([ + "id", + "email", + ]); + expect(imported.tables[1].fields.map((f) => f.name)).toEqual([ + "id", + "user_id", + ]); + expect(imported.tables[1].indices).toEqual([ + { id: 0, name: "idx_posts_user_id", unique: false, fields: ["user_id"] }, + ]); + + expect(imported.relationships).toHaveLength(1); + const relationship = imported.relationships[0]; + const startTable = imported.tables.find( + (t) => t.id === relationship.startTableId, + ); + const endTable = imported.tables.find( + (t) => t.id === relationship.endTableId, + ); + expect(startTable.name).toBe("posts"); + expect(endTable.name).toBe("users"); + expect(relationship.updateConstraint).toBe("No action"); + expect(relationship.deleteConstraint).toBe("Cascade"); + }); + + it("imports GO batches that are not terminated by semicolons", () => { + const ast = parseMssqlSource( + [ + "CREATE TABLE [users] ([id] INT, [email] NVARCHAR(255))", + "GO", + "CREATE TABLE [posts] ([id] INT, [user_id] INT)", + "GO", + ].join("\n"), + ); + const imported = importSQL(ast, DB.MSSQL, DB.MSSQL); + + expect(imported.tables.map((t) => t.name)).toEqual(["users", "posts"]); + }); + + it("imports foreign keys declared as named ALTER TABLE constraints", () => { + const ast = parseMssqlSource( + [ + "CREATE TABLE [users] ([id] INT, PRIMARY KEY([id]));", + "GO", + "CREATE TABLE [posts] ([id] INT, [user_id] INT, PRIMARY KEY([id]));", + "GO", + "ALTER TABLE [dbo].[posts]", + "ADD CONSTRAINT [fk_posts_user] FOREIGN KEY ([user_id])", + "REFERENCES [dbo].[users] ([id])", + "ON UPDATE SET NULL ON DELETE NO ACTION;", + "GO", + ].join("\n"), + ); + const imported = importSQL(ast, DB.MSSQL, DB.MSSQL); + + expect(imported.relationships).toHaveLength(1); + const relationship = imported.relationships[0]; + const startTable = imported.tables.find( + (t) => t.id === relationship.startTableId, + ); + const endTable = imported.tables.find( + (t) => t.id === relationship.endTableId, + ); + expect(startTable.name).toBe("posts"); + expect(endTable.name).toBe("users"); + expect(relationship.updateConstraint).toBe("Set null"); + expect(relationship.deleteConstraint).toBe("No action"); + }); + + it("imports statements without any GO batches", () => { + const ast = parseMssqlSource( + "CREATE TABLE [users] ([id] INT);\nCREATE TABLE [posts] ([id] INT);", + ); + const imported = importSQL(ast, DB.MSSQL, DB.MSSQL); + + expect(imported.tables.map((t) => t.name)).toEqual(["users", "posts"]); + }); + + it("imports a single statement without a terminator", () => { + const ast = parseMssqlSource("CREATE TABLE [users] ([id] INT)"); + const imported = importSQL(ast, DB.MSSQL, DB.MSSQL); + + expect(imported.tables.map((t) => t.name)).toEqual(["users"]); + }); + + it("keeps parsing when batches contain extended properties", () => { + const ast = parseMssqlSource( + [ + "CREATE TABLE [users] ([id] INT, PRIMARY KEY([id]));", + "GO", + "EXEC sys.sp_addextendedproperty", + " @name=N'MS_Description', @value=N'the users',", + " @level0type=N'SCHEMA',@level0name=N'dbo',", + " @level1type=N'TABLE',@level1name=N'users';", + "GO", + ].join("\n"), + ); + const imported = importSQL(ast, DB.MSSQL, DB.MSSQL); + + expect(imported.tables.map((t) => t.name)).toEqual(["users"]); + }); + + it("imports foreign keys without referential actions", () => { + const ast = parseMssqlSource( + [ + "CREATE TABLE [users] ([id] INT);", + "GO", + "CREATE TABLE [posts] ([user_id] INT);", + "GO", + "ALTER TABLE [posts] ADD FOREIGN KEY([user_id]) REFERENCES [users]([id]);", + "GO", + ].join("\n"), + ); + const imported = importSQL(ast, DB.MSSQL, DB.MSSQL); + + expect(imported.relationships).toHaveLength(1); + expect(imported.relationships[0].updateConstraint).toBe("No action"); + expect(imported.relationships[0].deleteConstraint).toBe("No action"); + }); + + it("imports foreign keys with multi-line column lists", () => { + const ast = parseMssqlSource( + [ + "CREATE TABLE [users] ([id] INT);", + "GO", + "CREATE TABLE [posts] ([user_id] INT);", + "GO", + "ALTER TABLE [posts]", + "ADD CONSTRAINT [fk_posts_user]", + "FOREIGN KEY", + "(", + " [user_id]", + ")", + "REFERENCES [users]", + "(", + " [id]", + ")", + "ON DELETE CASCADE;", + "GO", + ].join("\n"), + ); + const imported = importSQL(ast, DB.MSSQL, DB.MSSQL); + + expect(imported.relationships).toHaveLength(1); + expect(imported.relationships[0].deleteConstraint).toBe("Cascade"); + }); +});