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 .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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 { stripPostgresTextCastsInChecks } from "../../../utils/importSQL/preprocess";
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
? stripPostgresTextCastsInChecks(importSource.src)
: importSource.src;

ast = parser.astify(source, {
database: targetDatabase,
});
}
Expand Down
145 changes: 145 additions & 0 deletions src/utils/importSQL/preprocess.js
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 39 additions & 0 deletions tests/postgres-import.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading