From b26f0f10c6913015d34609ea9365f1bec68abad7 Mon Sep 17 00:00:00 2001 From: yzxcj797 <1784931579@qq.com> Date: Thu, 20 Aug 2026 12:46:45 +0800 Subject: [PATCH] Fix PostgreSQL regex check import --- .github/workflows/build.yml | 1 + package.json | 1 + src/components/EditorHeader/Modal/Modal.jsx | 8 +- src/utils/importSQL/preprocess.js | 145 ++++++++++++++++++++ tests/postgres-import.test.js | 39 ++++++ 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 src/utils/importSQL/preprocess.js create mode 100644 tests/postgres-import.test.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a2fe5b206..a1b5d1c89 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,6 +24,7 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm install + - run: npm test - name: Run eslint run: npm run lint - name: Run vite build diff --git a/package.json b/package.json index 848104764..382b30f10 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "test": "node --test tests/postgres-import.test.js", "build": "vite build", "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" diff --git a/src/components/EditorHeader/Modal/Modal.jsx b/src/components/EditorHeader/Modal/Modal.jsx index 149549d3a..29b8f49cf 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 { stripPostgresTextCastsInChecks } from "../../../utils/importSQL/preprocess"; import { allowedTypesFor, normalizeAiDiagram, @@ -131,7 +132,12 @@ export default function Modal({ } else { const parser = new Parser(); - ast = parser.astify(importSource.src, { + const source = + targetDatabase === DB.POSTGRES + ? stripPostgresTextCastsInChecks(importSource.src) + : importSource.src; + + ast = parser.astify(source, { database: targetDatabase, }); } diff --git a/src/utils/importSQL/preprocess.js b/src/utils/importSQL/preprocess.js new file mode 100644 index 000000000..3fd0a01eb --- /dev/null +++ b/src/utils/importSQL/preprocess.js @@ -0,0 +1,145 @@ +const CHECK_KEYWORD_LENGTH = 5; + +/** + * Remove text casts from string literals in PostgreSQL CHECK expressions. + * + * `node-sql-parser` cannot parse the `::text` cast pgAdmin emits after a + * regex pattern in a CHECK constraint. The cast does not change the pattern + * consumed by the regex operator, so removing it lets the rest of the dump + * import without discarding the constraint. + */ +export function stripPostgresTextCastsInChecks(src) { + if (typeof src !== "string" || src === "") return src; + + let result = ""; + let index = 0; + + while (index < src.length) { + if (src.startsWith("--", index)) { + const end = src.indexOf("\n", index + 2); + const stop = end === -1 ? src.length : end + 1; + result += src.slice(index, stop); + index = stop; + continue; + } + + if (src.startsWith("/*", index)) { + const end = src.indexOf("*/", index + 2); + const stop = end === -1 ? src.length : end + 2; + result += src.slice(index, stop); + index = stop; + continue; + } + + if (src[index] === "'" || src[index] === '"') { + const stop = findStringEnd(src, index); + result += src.slice(index, stop); + index = stop; + continue; + } + + if (isCheckKeyword(src, index)) { + result += src.slice(index, index + CHECK_KEYWORD_LENGTH); + index += CHECK_KEYWORD_LENGTH; + + const expressionStart = skipWhitespace(src, index); + if (src[expressionStart] === "(") { + const expressionEnd = findMatchingParen(src, expressionStart); + if (expressionStart < expressionEnd) { + result += src.slice(index, expressionStart); + result += stripTextCastsFromStringLiterals( + src.slice(expressionStart, expressionEnd + 1), + ); + index = expressionEnd + 1; + } + } + continue; + } + + result += src[index]; + index += 1; + } + + return result; +} + +function isCheckKeyword(src, index) { + const before = src[index - 1]; + const after = src[index + CHECK_KEYWORD_LENGTH]; + const isBoundaryBefore = !before || /[\s(]/.test(before); + const isBoundaryAfter = !after || /[\s(]/.test(after); + + return ( + isBoundaryBefore && + isBoundaryAfter && + src.slice(index, index + CHECK_KEYWORD_LENGTH).toLowerCase() === "check" + ); +} + +function findStringEnd(src, start) { + const quote = src[start]; + + for (let index = start + 1; index < src.length; index += 1) { + if (src[index] !== quote) continue; + + if (quote === "'" && src[index + 1] === "'") { + index += 1; + } else { + return index + 1; + } + } + + return src.length; +} + +function skipWhitespace(src, start) { + let index = start; + while (index < src.length && /\s/.test(src[index])) index += 1; + return index; +} + +function findMatchingParen(src, start) { + let depth = 0; + let index = start; + + while (index < src.length) { + const quote = src[index]; + if (quote === "'" || quote === '"') { + index = findStringEnd(src, index); + continue; + } + + if (src[index] === "(") depth += 1; + if (src[index] === ")") { + depth -= 1; + if (depth === 0) return index; + } + index += 1; + } + + return -1; +} + +function stripTextCastsFromStringLiterals(expression) { + let result = ""; + let index = 0; + + while (index < expression.length) { + if (expression[index] !== "'") { + result += expression[index]; + index += 1; + continue; + } + + const literalEnd = findStringEnd(expression, index); + result += expression.slice(index, literalEnd); + index = literalEnd; + + const cast = /^[ \t\r\n]*::[ \t\r\n]*text\b/i.exec( + expression.slice(literalEnd), + ); + if (cast) index += cast[0].length; + } + + return result; +} diff --git a/tests/postgres-import.test.js b/tests/postgres-import.test.js new file mode 100644 index 000000000..3f75fde9e --- /dev/null +++ b/tests/postgres-import.test.js @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import sqlParser from "node-sql-parser"; + +import { stripPostgresTextCastsInChecks } from "../src/utils/importSQL/preprocess.js"; + +const { Parser } = sqlParser; + +const dump = ` +CREATE DOMAIN public.valid_etld AS character varying(63) + CONSTRAINT valid_etld_check CHECK (((VALUE)::text ~ '^(xn--[a-z0-9]{1,59})$'::text)); +CREATE TABLE public.messages ( + id integer PRIMARY KEY, + address character varying(63) NOT NULL +); +`; + +test("PostgreSQL import accepts a regex check with a text-cast pattern", () => { + const source = stripPostgresTextCastsInChecks(dump); + const ast = new Parser().astify(source, { database: "postgresql" }); + + assert.equal(ast.length, 2); + assert.equal(ast[0].keyword, "domain"); + assert.equal(ast[1].keyword, "table"); +}); + +test("text-cast normalization is scoped to string literals in checks", () => { + const source = ` +CREATE TABLE public.examples ( + value text CHECK (value ~ 'literal::text'::text), + explanation text DEFAULT 'not-a-check'::text +); +`; + const normalized = stripPostgresTextCastsInChecks(source); + + assert.match(normalized, /'literal::text'/); + assert.doesNotMatch(normalized, /'literal::text'::text/); + assert.match(normalized, /'not-a-check'::text/); +});