Skip to content
Merged
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
18 changes: 18 additions & 0 deletions __fixtures__/generated/generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -21374,6 +21374,24 @@
"misc/issues-20.sql": "CREATE TABLE test_exclude_where (\n id uuid PRIMARY KEY,\n database_id uuid NOT NULL,\n status text NOT NULL DEFAULT 'pending',\n EXCLUDE USING btree (database_id WITH =)\n WHERE (status = 'pending')\n)",
"misc/issues-21.sql": "CREATE TABLE test_named_exclude (\n id uuid PRIMARY KEY,\n database_id uuid NOT NULL,\n status text NOT NULL DEFAULT 'pending',\n CONSTRAINT one_pending_per_database\n EXCLUDE USING btree (database_id WITH =)\n WHERE (status = 'pending')\n)",
"misc/issues-22.sql": "ALTER TABLE test_named_exclude ADD CONSTRAINT no_overlap EXCLUDE USING gist (room WITH =, during WITH &&)",
"misc/issue-349-fk-set-cols-1.sql": "ALTER TABLE ONLY child ADD CONSTRAINT child_fk FOREIGN KEY (a, b) REFERENCES parent(a, b) ON DELETE SET NULL (b)",
"misc/issue-349-fk-set-cols-2.sql": "ALTER TABLE ONLY child ADD CONSTRAINT child_fk FOREIGN KEY (a, b) REFERENCES parent(a, b) ON DELETE SET DEFAULT (b)",
"misc/issue-349-fk-set-cols-3.sql": "ALTER TABLE ONLY child ADD CONSTRAINT child_fk FOREIGN KEY (a, b) REFERENCES parent(a, b) ON UPDATE CASCADE ON DELETE SET NULL (a, b)",
"misc/issue-349-fk-set-cols-4.sql": "CREATE TABLE child (a int, b int, FOREIGN KEY (a, b) REFERENCES parent (a, b) ON DELETE SET NULL (b))",
"misc/issue-348-350-partition-cmd-1.sql": "ALTER TABLE ONLY public.measurement ATTACH PARTITION public.measurement_y2024 FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')",
"misc/issue-348-350-partition-cmd-2.sql": "ALTER TABLE ONLY public.measurement DETACH PARTITION public.measurement_y2024",
"misc/issue-348-350-partition-cmd-3.sql": "ALTER TABLE public.measurement DETACH PARTITION public.measurement_y2024 CONCURRENTLY",
"misc/issue-348-350-partition-cmd-4.sql": "ALTER TABLE ONLY measurement ATTACH PARTITION measurement_y2024 FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')",
"misc/issue-348-350-partition-cmd-5.sql": "ALTER TABLE ONLY o ATTACH PARTITION o_p FOR VALUES WITH (MODULUS 4, REMAINDER 0)",
"misc/issue-348-350-partition-cmd-6.sql": "ALTER TABLE ONLY o ATTACH PARTITION o_p FOR VALUES WITH (MODULUS 4, REMAINDER 1)",
"misc/issue-348-350-partition-cmd-7.sql": "ALTER TABLE ONLY s.o ATTACH PARTITION s.o_p DEFAULT",
"misc/issue-348-350-partition-cmd-8.sql": "ALTER TABLE ONLY o ATTACH PARTITION o_p FOR VALUES IN (1, 2)",
"misc/issue-348-350-partition-cmd-9.sql": "CREATE TABLE o_p0 PARTITION OF o FOR VALUES WITH (MODULUS 4, REMAINDER 0)",
"misc/issue-292-merge-when-1.sql": "MERGE INTO t AS target USING (SELECT 1 AS id) AS source ON target.id = source.id WHEN MATCHED THEN UPDATE SET name = 'x' WHEN NOT MATCHED THEN INSERT (id, name) VALUES (source.id, 'x')",
"misc/issue-292-merge-when-2.sql": "MERGE INTO t AS target USING (SELECT 1 AS id) AS source ON target.id = source.id WHEN MATCHED AND cond THEN DELETE",
"misc/issue-292-merge-when-3.sql": "MERGE INTO t AS target USING (SELECT 1 AS id) AS source ON target.id = source.id WHEN NOT MATCHED BY SOURCE THEN UPDATE SET name = 'x'",
"misc/issue-292-merge-when-4.sql": "MERGE INTO t AS target USING (SELECT 1 AS id) AS source ON target.id = source.id WHEN MATCHED THEN DO NOTHING",
"misc/issue-292-merge-when-5.sql": "MERGE INTO t AS target USING (SELECT 1 AS id) AS source ON target.id = source.id WHEN NOT MATCHED THEN INSERT DEFAULT VALUES",
"misc/inflection-1.sql": "CREATE SCHEMA inflection",
"misc/inflection-2.sql": "GRANT USAGE ON SCHEMA inflection TO PUBLIC",
"misc/inflection-3.sql": "ALTER DEFAULT PRIVILEGES IN SCHEMA inflection \n GRANT EXECUTE ON FUNCTIONS TO PUBLIC",
Expand Down
6 changes: 6 additions & 0 deletions __fixtures__/kitchen-sink/misc/issue-292-merge-when.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Ref: constructive-io/pgsql-parser#292
MERGE INTO t AS target USING (SELECT 1 AS id) AS source ON target.id = source.id WHEN MATCHED THEN UPDATE SET name = 'x' WHEN NOT MATCHED THEN INSERT (id, name) VALUES (source.id, 'x');
MERGE INTO t AS target USING (SELECT 1 AS id) AS source ON target.id = source.id WHEN MATCHED AND cond THEN DELETE;
MERGE INTO t AS target USING (SELECT 1 AS id) AS source ON target.id = source.id WHEN NOT MATCHED BY SOURCE THEN UPDATE SET name = 'x';
MERGE INTO t AS target USING (SELECT 1 AS id) AS source ON target.id = source.id WHEN MATCHED THEN DO NOTHING;
MERGE INTO t AS target USING (SELECT 1 AS id) AS source ON target.id = source.id WHEN NOT MATCHED THEN INSERT DEFAULT VALUES;
11 changes: 11 additions & 0 deletions __fixtures__/kitchen-sink/misc/issue-348-350-partition-cmd.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Ref: constructive-io/pgsql-parser#348
-- Ref: constructive-io/pgsql-parser#350
ALTER TABLE ONLY public.measurement ATTACH PARTITION public.measurement_y2024 FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
ALTER TABLE ONLY public.measurement DETACH PARTITION public.measurement_y2024;
ALTER TABLE public.measurement DETACH PARTITION public.measurement_y2024 CONCURRENTLY;
ALTER TABLE ONLY measurement ATTACH PARTITION measurement_y2024 FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
ALTER TABLE ONLY o ATTACH PARTITION o_p FOR VALUES WITH (MODULUS 4, REMAINDER 0);
ALTER TABLE ONLY o ATTACH PARTITION o_p FOR VALUES WITH (MODULUS 4, REMAINDER 1);
ALTER TABLE ONLY s.o ATTACH PARTITION s.o_p DEFAULT;
ALTER TABLE ONLY o ATTACH PARTITION o_p FOR VALUES IN (1, 2);
CREATE TABLE o_p0 PARTITION OF o FOR VALUES WITH (MODULUS 4, REMAINDER 0);
5 changes: 5 additions & 0 deletions __fixtures__/kitchen-sink/misc/issue-349-fk-set-cols.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Ref: constructive-io/pgsql-parser#349
ALTER TABLE ONLY child ADD CONSTRAINT child_fk FOREIGN KEY (a, b) REFERENCES parent(a, b) ON DELETE SET NULL (b);
ALTER TABLE ONLY child ADD CONSTRAINT child_fk FOREIGN KEY (a, b) REFERENCES parent(a, b) ON DELETE SET DEFAULT (b);
ALTER TABLE ONLY child ADD CONSTRAINT child_fk FOREIGN KEY (a, b) REFERENCES parent(a, b) ON UPDATE CASCADE ON DELETE SET NULL (a, b);
CREATE TABLE child (a int, b int, FOREIGN KEY (a, b) REFERENCES parent (a, b) ON DELETE SET NULL (b));
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@

import { FixtureTestUtils } from '../../test-utils';
const fixtures = new FixtureTestUtils();

it('misc-issue-292-merge-when', async () => {
await fixtures.runFixtureTests([
"misc/issue-292-merge-when-1.sql",
"misc/issue-292-merge-when-2.sql",
"misc/issue-292-merge-when-3.sql",
"misc/issue-292-merge-when-4.sql",
"misc/issue-292-merge-when-5.sql"
]);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@

import { FixtureTestUtils } from '../../test-utils';
const fixtures = new FixtureTestUtils();

it('misc-issue-348-350-partition-cmd', async () => {
await fixtures.runFixtureTests([
"misc/issue-348-350-partition-cmd-1.sql",
"misc/issue-348-350-partition-cmd-2.sql",
"misc/issue-348-350-partition-cmd-3.sql",
"misc/issue-348-350-partition-cmd-4.sql",
"misc/issue-348-350-partition-cmd-5.sql",
"misc/issue-348-350-partition-cmd-6.sql",
"misc/issue-348-350-partition-cmd-7.sql",
"misc/issue-348-350-partition-cmd-8.sql",
"misc/issue-348-350-partition-cmd-9.sql"
]);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@

import { FixtureTestUtils } from '../../test-utils';
const fixtures = new FixtureTestUtils();

it('misc-issue-349-fk-set-cols', async () => {
await fixtures.runFixtureTests([
"misc/issue-349-fk-set-cols-1.sql",
"misc/issue-349-fk-set-cols-2.sql",
"misc/issue-349-fk-set-cols-3.sql",
"misc/issue-349-fk-set-cols-4.sql"
]);
});
22 changes: 15 additions & 7 deletions packages/deparser/src/deparser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3079,6 +3079,15 @@ export class Deparser implements DeparserVisitor {
deleteClause += 'SET DEFAULT';
break;
}
if ((node.fk_del_action === 'n' || node.fk_del_action === 'd') && node.fk_del_set_cols && node.fk_del_set_cols.length > 0) {
const setColumns = ListUtils.unwrapList(node.fk_del_set_cols)
.map(column => {
const stringNode = (column as { String: { sval?: string; str?: string } }).String;
return QuoteUtils.quoteIdentifier(stringNode.sval || stringNode.str || '');
})
.join(', ');
deleteClause += ` (${setColumns})`;
}
if (context.isPretty()) {
output.push('\n' + context.indent(deleteClause));
} else {
Expand Down Expand Up @@ -3917,12 +3926,12 @@ export class Deparser implements DeparserVisitor {
PartitionCmd(node: t.PartitionCmd, context: DeparserContext): string {
const output: string[] = [];

if (node.concurrent) {
output.push('CONCURRENTLY');
if (node.name) {
output.push(this.RangeVar(node.name, context));
}

if (node.name) {
output.push(this.visit(node.name as any, context));
if (node.concurrent) {
output.push('CONCURRENTLY');
}

if (node.bound) {
Expand All @@ -3947,9 +3956,9 @@ export class Deparser implements DeparserVisitor {
.join(', ');
output.push(`(${upperValues})`);
}
} else if (node.bound.strategy === 'h' && node.bound.modulus !== undefined && node.bound.remainder !== undefined) {
} else if (node.bound.strategy === 'h' && node.bound.modulus !== undefined) {
output.push('FOR VALUES WITH');
output.push(`(modulus ${node.bound.modulus}, remainder ${node.bound.remainder})`);
output.push(`(MODULUS ${node.bound.modulus}, REMAINDER ${node.bound.remainder ?? 0})`);
} else if (node.bound.is_default) {
output.push('DEFAULT');
}
Expand Down Expand Up @@ -11644,4 +11653,3 @@ export class Deparser implements DeparserVisitor {
return stringLiteralRegex.test(content);
}
}

67 changes: 67 additions & 0 deletions packages/plpgsql-parser/__tests__/walk-sql-body-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { loadModule, parseSync, walkSql } from '../src';

beforeAll(async () => {
await loadModule();
});

const BROKEN_FUNCTION_SQL = `
CREATE FUNCTION broken_connectors() RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
UPDATE connectors SET instance_id = ;
END;
$$;
`;

describe('walkSql PL/pgSQL body errors', () => {
it('reports a broken PL/pgSQL body as an abort and records its statement index', () => {
const parsed = parseSync(BROKEN_FUNCTION_SQL);
expect(parsed.errors).toHaveLength(1);
expect(parsed.errors[0].stmtIndex).toBe(0);

const result = walkSql(BROKEN_FUNCTION_SQL, {});
expect(result.aborted).toBe(true);
expect(result.reason).toBeTruthy();
expect(result.reasons).toEqual([parsed.errors[0].message]);
});

it('walks valid PL/pgSQL bodies', () => {
const visited: string[] = [];
const result = walkSql(
`
CREATE FUNCTION valid_connectors() RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
UPDATE connectors SET instance_id = 1;
END;
$$;
`,
(path) => {
if (path.tag.startsWith('PLpgSQL_')) {
visited.push(path.tag);
}
}
);

expect(result.aborted).toBe(false);
expect(visited).toContain('PLpgSQL_function');
expect(visited).toContain('PLpgSQL_stmt_execsql');
});

it('does not parse broken bodies when walkFunctionBodies is false', () => {
const result = walkSql(BROKEN_FUNCTION_SQL, {}, { walkFunctionBodies: false });
expect(result.aborted).toBe(false);
});

it('does not inspect non-PL/pgSQL function bodies', () => {
const result = walkSql(
`
CREATE FUNCTION sql_function() RETURNS integer
LANGUAGE sql AS $$ THIS IS NOT VALID SQL BODY TEXT $$;
`,
{},
);

expect(result.aborted).toBe(false);
});
});
55 changes: 35 additions & 20 deletions packages/plpgsql-parser/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
ParsedFunction,
ParsedItem,
ParsedScript,
ParsedScriptError,
ParsedStatement,
ParseOptions
} from './types';
Expand Down Expand Up @@ -68,15 +69,19 @@ function getStatementSql(sqlBuffer: Buffer, rawStmt: any): string {
return sqlBuffer.slice(start, end).toString('utf8');
}

function extractFunctionInfo(stmt: any, stmtIndex: number, stmtSql: string): ParsedFunction | null {
function extractFunctionInfo(
stmt: any,
stmtIndex: number,
stmtSql: string
): { fn: ParsedFunction | null; error?: string } {
const createFunctionStmt = stmt?.CreateFunctionStmt;
if (!createFunctionStmt) return null;
if (!createFunctionStmt) return { fn: null };

const language = getLanguageFromOptions(createFunctionStmt.options);
if (language !== 'plpgsql') return null;
if (language !== 'plpgsql') return { fn: null };

const body = getBodyFromOptions(createFunctionStmt.options);
if (!body) return null;
if (!body) return { fn: null };

try {
// Parse only this statement's SQL. Parsing the full script would return
Expand All @@ -86,20 +91,25 @@ function extractFunctionInfo(stmt: any, stmtIndex: number, stmtSql: string): Par
const { ast: hydrated, stats, errors } = hydratePlpgsqlAst(plpgsqlRaw);

return {
kind: 'plpgsql-function',
stmt: createFunctionStmt,
stmtIndex,
language: language || 'plpgsql',
body,
plpgsql: {
raw: plpgsqlRaw,
hydrated,
stats,
errors
fn: {
kind: 'plpgsql-function',
stmt: createFunctionStmt,
stmtIndex,
language: language || 'plpgsql',
body,
plpgsql: {
raw: plpgsqlRaw,
hydrated,
stats,
errors
}
}
};
} catch (err) {
return null;
return {
fn: null,
error: err instanceof Error ? err.message : String(err)
};
}
}

Expand All @@ -109,6 +119,7 @@ export function parse(sql: string, options: ParseOptions = {}): ParsedScript {
const sqlResult: ParseResult = parseSqlSync(sql);
const items: ParsedItem[] = [];
const functions: ParsedFunction[] = [];
const errors: ParsedScriptError[] = [];
const sqlBuffer = Buffer.from(sql, 'utf8');

if (sqlResult.stmts) {
Expand All @@ -117,12 +128,15 @@ export function parse(sql: string, options: ParseOptions = {}): ParsedScript {
const stmt = rawStmt?.stmt;

if (stmt && isPlpgsqlFunction(stmt) && hydrate) {
const fnInfo = extractFunctionInfo(stmt, i, getStatementSql(sqlBuffer, rawStmt));
if (fnInfo) {
items.push(fnInfo);
functions.push(fnInfo);
const result = extractFunctionInfo(stmt, i, getStatementSql(sqlBuffer, rawStmt));
if (result.fn) {
items.push(result.fn);
functions.push(result.fn);
continue;
}
if (result.error) {
errors.push({ stmtIndex: i, message: result.error });
}
}

const stmtItem: ParsedStatement = {
Expand All @@ -137,7 +151,8 @@ export function parse(sql: string, options: ParseOptions = {}): ParsedScript {
return {
sql: sqlResult,
items,
functions
functions,
errors
};
}

Expand Down
13 changes: 11 additions & 2 deletions packages/plpgsql-parser/src/traverse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ export interface WalkSqlOptions extends WalkOptions {
* });
* ```
*
* Unparseable input is reported as an abort rather than a thrown error, so a
* validator can treat "rejected" and "could not be understood" uniformly.
* Unparseable SQL or PL/pgSQL function bodies are reported as an abort rather
* than a thrown error, so a validator can treat "rejected" and "could not be
* understood" uniformly.
*/
export function walkSql(
sql: string,
Expand All @@ -62,5 +63,13 @@ export function walkSql(
return { aborted: true, reason, reasons: [reason] };
}

if (walkFunctionBodies && parsed.errors.length > 0) {
return {
aborted: true,
reason: parsed.errors[0].message,
reasons: parsed.errors.map(error => error.message)
};
}

return walk(parsed, visitors, { ...options, walkFunctionBodies });
}
6 changes: 6 additions & 0 deletions packages/plpgsql-parser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,16 @@ export interface ParsedStatement {

export type ParsedItem = ParsedFunction | ParsedStatement;

export interface ParsedScriptError {
stmtIndex: number;
message: string;
}

export interface ParsedScript {
sql: ParseResult;
items: ParsedItem[];
functions: ParsedFunction[];
errors: ParsedScriptError[];
}

export interface ParseOptions {
Expand Down
2 changes: 1 addition & 1 deletion packages/scripts/src/invert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ function invertAlterTable(node: AnyNode, warnings: string[]): Emitted[] {
}
out.push(alterWith({
subtype: 'AT_DetachPartition',
def: { PartitionCmd: { name: { RangeVar: clone(partition) } } },
def: { PartitionCmd: { name: clone(partition) } },
behavior: 'DROP_RESTRICT'
}));
break;
Expand Down
Loading
Loading