Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 7 additions & 1 deletion src/components/EditorHeader/Modal/Modal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
}
Expand Down
242 changes: 242 additions & 0 deletions src/utils/importSQL/postgresLikeTables.js
Original file line number Diff line number Diff line change
@@ -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");
}
70 changes: 70 additions & 0 deletions src/utils/importSQL/postgresLikeTables.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading