From f6833d5246d4f77a8631c48c2522d4e119991e55 Mon Sep 17 00:00:00 2001 From: yzxcj797 <1784931579@qq.com> Date: Tue, 18 Aug 2026 22:28:29 +0800 Subject: [PATCH] Expand PostgreSQL CREATE TABLE LIKE imports Expand LIKE clauses from earlier table definitions before SQL parsing, preserving extra columns, quoted identifiers, and statement text that the parser already supports. Unresolved sources are left unchanged rather than guessed. --- package.json | 1 + src/components/EditorHeader/Modal/Modal.jsx | 8 +- src/utils/importSQL/postgresLikeTables.js | 242 ++++++++++++++++++ .../importSQL/postgresLikeTables.test.js | 70 +++++ 4 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 src/utils/importSQL/postgresLikeTables.js create mode 100644 src/utils/importSQL/postgresLikeTables.test.js diff --git a/package.json b/package.json index 848104764..4ddf6e2b6 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "test": "node --test", "preview": "vite preview" }, "dependencies": { diff --git a/src/components/EditorHeader/Modal/Modal.jsx b/src/components/EditorHeader/Modal/Modal.jsx index 149549d3a..18492d5e8 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 { expandPostgresCreateTableLike } from "../../../utils/importSQL/postgresLikeTables"; 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 + ? expandPostgresCreateTableLike(importSource.src) + : importSource.src; + + ast = parser.astify(source, { database: targetDatabase, }); } diff --git a/src/utils/importSQL/postgresLikeTables.js b/src/utils/importSQL/postgresLikeTables.js new file mode 100644 index 000000000..ad789c15a --- /dev/null +++ b/src/utils/importSQL/postgresLikeTables.js @@ -0,0 +1,242 @@ +const IDENTIFIER = + '(?:"(?:[^"]|"")+"|[\\p{L}_][\\p{L}\\p{N}_$]*)(?:\\s*\\.\\s*(?:"(?:[^"]|"")+"|[\\p{L}_][\\p{L}\\p{N}_$]*))*'; +const CREATE_TABLE_PREFIX = new RegExp( + `^\\s*CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?(${IDENTIFIER})\\s*\\(`, + "iu", +); +const LIKE_PREFIX = new RegExp(`^\\s*LIKE\\s+(${IDENTIFIER})`, "iu"); +const LIKE_OPTION = + /^\s*(?:INCLUDING|EXCLUDING)\s+(?:ALL|DEFAULTS|CONSTRAINTS|INDEXES|STORAGE|COMMENTS|GENERATED|IDENTITY|STATISTICS)(?:\s*,\s*(?:INCLUDING|EXCLUDING)\s+(?:ALL|DEFAULTS|CONSTRAINTS|INDEXES|STORAGE|COMMENTS|GENERATED|IDENTITY|STATISTICS))*\s*/i; + +function splitPostgresStatements(sql) { + const statements = []; + let start = 0; + let quote = null; + let dollarTag = null; + let blockCommentDepth = 0; + let index = 0; + + while (index < sql.length) { + const char = sql[index]; + + if (blockCommentDepth > 0) { + if (sql.startsWith("/*", index)) { + blockCommentDepth += 1; + index += 2; + } else if (sql.startsWith("*/", index)) { + blockCommentDepth -= 1; + index += 2; + } else { + index += 1; + } + } else if (dollarTag) { + if (sql.startsWith(dollarTag, index)) { + index += dollarTag.length; + dollarTag = null; + } else { + index += 1; + } + } else if (quote) { + if (char === quote) { + if (sql[index + 1] === quote) index += 2; + else { + quote = null; + index += 1; + } + } else { + index += 1; + } + } else if (char === "'" || char === '"') { + quote = char; + index += 1; + } else if (char === "-" && sql[index + 1] === "-") { + index += 2; + while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") { + index += 1; + } + } else if (char === "/" && sql[index + 1] === "*") { + blockCommentDepth = 1; + index += 2; + } else if (char === "$") { + const closingTag = sql.indexOf("$", index + 1); + const candidate = + closingTag === -1 ? "" : sql.slice(index, closingTag + 1); + if (/^\$[A-Za-z0-9_]*\$/.test(candidate)) { + dollarTag = candidate; + index += candidate.length; + } else { + index += 1; + } + } else if (char === ";") { + statements.push(sql.slice(start, index + 1)); + start = index + 1; + index += 1; + } else { + index += 1; + } + } + + if (start < sql.length) statements.push(sql.slice(start)); + return statements; +} + +function identifierParts(identifier) { + const partPattern = /"(?:[^"]|"")+"|[\p{L}_][\p{L}\p{N}_$]*/gu; + const parts = []; + let match; + while ((match = partPattern.exec(identifier)) !== null) { + const part = match[0]; + parts.push( + part.startsWith('"') + ? part.slice(1, -1).replace(/""/g, '"') + : part.toLowerCase(), + ); + } + return parts; +} + +function canonicalIdentifier(identifier) { + return identifierParts(identifier).join("."); +} + +function findCreateTableClose(statement, openIndex) { + let depth = 0; + let quote = null; + let dollarTag = null; + let index = openIndex; + + while (index < statement.length) { + const char = statement[index]; + + if (quote) { + if (char === quote) { + if (statement[index + 1] === quote) index += 2; + else { + quote = null; + index += 1; + } + } else { + index += 1; + } + } else if (dollarTag) { + if (statement.startsWith(dollarTag, index)) { + index += dollarTag.length; + dollarTag = null; + } else { + index += 1; + } + } else if (char === "'" || char === '"') { + quote = char; + index += 1; + } else if (char === "-" && statement[index + 1] === "-") { + index += 2; + while (index < statement.length && statement[index] !== "\n") { + index += 1; + } + } else if (char === "/" && statement[index + 1] === "*") { + const endIndex = statement.indexOf("*/", index + 2); + if (endIndex === -1) return null; + index = endIndex + 2; + } else if (char === "$") { + const closingTag = statement.indexOf("$", index + 1); + const candidate = + closingTag === -1 ? "" : statement.slice(index, closingTag + 1); + if (/^\$[A-Za-z0-9_]*\$/.test(candidate)) { + dollarTag = candidate; + index += candidate.length; + } else { + index += 1; + } + } else if (char === "(") { + depth += 1; + index += 1; + } else if (char === ")") { + depth -= 1; + index += 1; + if (depth === 0) return index - 1; + } else { + index += 1; + } + } + return null; +} + +function resolveTableBody(tableBodies, source) { + const sourceParts = identifierParts(source); + const directKey = sourceParts.join("."); + if (tableBodies.has(directKey)) return tableBodies.get(directKey); + + for (const [key, body] of tableBodies) { + const keyParts = key.split("."); + if (keyParts.length > sourceParts.length) { + const suffix = keyParts.slice(keyParts.length - sourceParts.length); + if (suffix.join(".") === directKey) return body; + } + } + return null; +} + +function createTableParts(statement) { + const match = CREATE_TABLE_PREFIX.exec(statement); + if (!match) return null; + + const openIndex = match.index + match[0].lastIndexOf("("); + const closeIndex = findCreateTableClose(statement, openIndex); + if (closeIndex <= openIndex) return null; + + return { + target: match[1], + prefix: statement.slice(0, openIndex + 1), + body: statement.slice(openIndex + 1, closeIndex), + suffix: statement.slice(closeIndex), + }; +} + +function withoutLikeClause(body, tableBodies) { + const match = LIKE_PREFIX.exec(body); + if (!match) return null; + + const sourceBody = resolveTableBody(tableBodies, match[1]); + if (sourceBody === null) return null; + + let remainder = body.slice(match.index + match[0].length); + let options; + while ((options = LIKE_OPTION.exec(remainder)) !== null) { + remainder = remainder.slice(options[0].length); + } + + remainder = remainder.trimStart(); + if (remainder.startsWith(",")) { + remainder = remainder.slice(1).trimStart(); + } else if (remainder.length > 0) { + return null; + } + + return [sourceBody.trim(), remainder.trim()].filter(Boolean).join(", "); +} + +export function expandPostgresCreateTableLike(sql) { + const statements = splitPostgresStatements(sql); + const tableBodies = new Map(); + const expanded = []; + + for (const statement of statements) { + const parts = createTableParts(statement); + if (!parts) { + expanded.push(statement); + continue; + } + + const expandedBody = withoutLikeClause(parts.body, tableBodies); + const nextStatement = expandedBody + ? `${parts.prefix}${expandedBody}${parts.suffix}` + : statement; + const nextParts = createTableParts(nextStatement); + if (nextParts) { + tableBodies.set(canonicalIdentifier(nextParts.target), nextParts.body); + } + expanded.push(nextStatement); + } + + return expanded.join("\n"); +} diff --git a/src/utils/importSQL/postgresLikeTables.test.js b/src/utils/importSQL/postgresLikeTables.test.js new file mode 100644 index 000000000..deb97c7fb --- /dev/null +++ b/src/utils/importSQL/postgresLikeTables.test.js @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import nodeSQLParser from "node-sql-parser"; + +import { expandPostgresCreateTableLike } from "./postgresLikeTables.js"; + +const { Parser } = nodeSQLParser; + +function parse(result) { + return new Parser().astify(result, { database: "postgresql" }); +} + +test("PostgreSQL CREATE TABLE LIKE expands from an earlier table", () => { + const result = expandPostgresCreateTableLike(`CREATE TABLE public.users ( + id integer PRIMARY KEY, + email varchar(255) NOT NULL +); +CREATE TABLE public.user_archive ( + LIKE public.users INCLUDING ALL +);`); + + assert.ok(result.includes("id integer PRIMARY KEY")); + const ast = parse(result); + assert.equal(ast.length, 2); + assert.equal(ast[1].table[0].table, "user_archive"); + assert.equal(ast[1].create_definitions.length, 2); +}); + +test("LIKE expansion supports extra columns and chained copies", () => { + const result = expandPostgresCreateTableLike(`CREATE TABLE public.source ( + id integer PRIMARY KEY, + value text +); +CREATE TABLE public.clone ( + LIKE public.source INCLUDING DEFAULTS, + archived_at timestamp +); +CREATE TABLE public.clone_copy ( + LIKE public.clone INCLUDING ALL +);`); + + const ast = parse(result); + const copyColumns = ast[2].create_definitions.map( + (definition) => definition.column.column.expr.value, + ); + assert.deepEqual(copyColumns, ["id", "value", "archived_at"]); +}); + +test("quoted identifiers and semicolons in defaults remain intact", () => { + const result = + expandPostgresCreateTableLike(`CREATE TABLE public."Source Table" ( + "value" text DEFAULT 'keep; this' +); +CREATE TABLE public."Clone Table" ( + LIKE public."Source Table" +);`); + + assert.ok(result.includes("keep; this")); + const ast = parse(result); + assert.equal(ast[1].table[0].table, "Clone Table"); + assert.equal(ast[1].create_definitions.length, 1); +}); + +test("an unresolved LIKE source is left unchanged", () => { + const sql = `CREATE TABLE public.missing_clone ( + LIKE public.missing_source INCLUDING ALL +);`; + + assert.equal(expandPostgresCreateTableLike(sql), sql); +});