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); +});