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
4 changes: 3 additions & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ jobs:
package:
- deparser
- parser
- plpgsql-deparser
- plpgsql-parser
- pgsql-cli
- proto-parser
- transform
Expand Down Expand Up @@ -39,4 +41,4 @@ jobs:
run: pnpm build

- name: test
run: pnpm --filter ${{ matrix.package }} test
run: pnpm --filter ${{ matrix.package }} test
14 changes: 14 additions & 0 deletions __fixtures__/plpgsql-generated/generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@
"plpgsql_domain-17.sql": "-- fail\n\nCREATE FUNCTION build_ordered_named_pair(i int, j int) RETURNS ordered_named_pair AS $$\nbegin\nreturn row(i, j);\nend\n$$ LANGUAGE plpgsql",
"plpgsql_domain-18.sql": "CREATE FUNCTION build_ordered_named_pairs(i int, j int) RETURNS ordered_named_pair[] AS $$\nbegin\nreturn array[row(i, j), row(i, j+1)];\nend\n$$ LANGUAGE plpgsql",
"plpgsql_domain-19.sql": "-- fail\n\nCREATE FUNCTION test_assign_ordered_named_pairs(x int, y int, z int)\n RETURNS ordered_named_pair[] AS $$\ndeclare v ordered_named_pair[] := array[row(x, y)];\nbegin\n-- ideally this would work, but it doesn't yet:\n-- v[1].j := z;\nreturn v;\nend\n$$ LANGUAGE plpgsql",
"plpgsql_deparser_fixes-1.sql": "-- Fixtures to test deparser fixes from constructive-db PR #229\n-- These exercise: PERFORM, INTO clause placement, record field qualification, RETURN handling\n\n-- Test 1: PERFORM statement (parser stores as SELECT, deparser must strip SELECT)\nCREATE FUNCTION test_perform_basic() RETURNS trigger\nLANGUAGE plpgsql AS $$\nBEGIN\n PERFORM pg_notify('test_channel', 'message');\n RETURN NEW;\nEND$$",
"plpgsql_deparser_fixes-2.sql": "-- Test 2: PERFORM with function call and arguments\nCREATE FUNCTION test_perform_with_args() RETURNS trigger\nLANGUAGE plpgsql AS $$\nBEGIN\n IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN\n PERFORM pg_notify(TG_ARGV[0], to_json(NEW)::text);\n RETURN NEW;\n END IF;\n IF (TG_OP = 'DELETE') THEN\n PERFORM pg_notify(TG_ARGV[0], to_json(OLD)::text);\n RETURN OLD;\n END IF;\n RETURN NULL;\nEND$$",
"plpgsql_deparser_fixes-3.sql": "-- Test 3: INTO clause with record field target (recfield qualification)\nCREATE FUNCTION test_into_record_field() RETURNS trigger\nLANGUAGE plpgsql AS $$\nBEGIN\n SELECT\n NEW.is_approved IS TRUE\n AND NEW.is_verified IS TRUE\n AND NEW.is_disabled IS FALSE INTO NEW.is_active;\n RETURN NEW;\nEND$$",
"plpgsql_deparser_fixes-4.sql": "-- Test 4: INTO clause with subquery (depth-aware scanner must skip nested FROM)\nCREATE FUNCTION test_into_with_subquery() RETURNS trigger\nLANGUAGE plpgsql AS $$\nDECLARE\n result_value int;\nBEGIN\n SELECT count(*) INTO result_value\n FROM (SELECT id FROM users WHERE id = NEW.user_id) sub;\n RETURN NEW;\nEND$$",
"plpgsql_deparser_fixes-5.sql": "-- Test 5: INTO clause with multiple record fields\nCREATE FUNCTION test_into_multiple_fields() RETURNS trigger\nLANGUAGE plpgsql AS $$\nBEGIN\n SELECT is_active, is_verified INTO NEW.is_active, NEW.is_verified\n FROM users WHERE id = NEW.user_id;\n RETURN NEW;\nEND$$",
"plpgsql_deparser_fixes-6.sql": "-- Test 6: SETOF function with RETURN QUERY and bare RETURN\nCREATE FUNCTION test_setof_return_query(p_limit int)\nRETURNS SETOF int\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN QUERY SELECT generate_series(1, p_limit);\n RETURN;\nEND$$",
"plpgsql_deparser_fixes-7.sql": "-- Test 7: SETOF function with RETURN NEXT\nCREATE FUNCTION test_setof_return_next(p_count int)\nRETURNS SETOF text\nLANGUAGE plpgsql AS $$\nDECLARE\n i int;\nBEGIN\n FOR i IN 1..p_count LOOP\n RETURN NEXT 'item_' || i::text;\n END LOOP;\n RETURN;\nEND$$",
"plpgsql_deparser_fixes-8.sql": "-- Test 8: Void function with bare RETURN\nCREATE FUNCTION test_void_function(p_value text)\nRETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n RAISE NOTICE 'Value: %', p_value;\n RETURN;\nEND$$",
"plpgsql_deparser_fixes-9.sql": "-- Test 9: Scalar function with RETURN NULL\nCREATE FUNCTION test_scalar_return_null()\nRETURNS int\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN NULL;\nEND$$",
"plpgsql_deparser_fixes-10.sql": "-- Test 10: Scalar function with conditional RETURN\nCREATE FUNCTION test_scalar_conditional(p_value int)\nRETURNS int\nLANGUAGE plpgsql AS $$\nBEGIN\n IF p_value > 0 THEN\n RETURN p_value * 2;\n END IF;\n RETURN NULL;\nEND$$",
"plpgsql_deparser_fixes-11.sql": "-- Test 11: OUT parameter function with bare RETURN\nCREATE FUNCTION test_out_params(OUT ok boolean, OUT message text)\nLANGUAGE plpgsql AS $$\nBEGIN\n ok := true;\n message := 'success';\n RETURN;\nEND$$",
"plpgsql_deparser_fixes-12.sql": "-- Test 12: RETURNS TABLE function with RETURN QUERY\nCREATE FUNCTION test_returns_table(p_prefix text)\nRETURNS TABLE(id int, name text)\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN QUERY SELECT 1, p_prefix || '_one';\n RETURN QUERY SELECT 2, p_prefix || '_two';\n RETURN;\nEND$$",
"plpgsql_deparser_fixes-13.sql": "-- Test 13: Trigger function with complex logic\nCREATE FUNCTION test_trigger_complex() RETURNS trigger\nLANGUAGE plpgsql AS $$\nDECLARE\n defaults_record record;\n bit_len int;\nBEGIN\n bit_len := bit_length(NEW.permissions);\n \n SELECT * INTO defaults_record\n FROM permission_defaults AS t\n LIMIT 1;\n \n IF found THEN\n NEW.is_approved := defaults_record.is_approved;\n NEW.is_verified := defaults_record.is_verified;\n END IF;\n \n IF NEW.is_owner IS TRUE THEN\n NEW.is_admin := true;\n NEW.is_approved := true;\n NEW.is_verified := true;\n END IF;\n \n SELECT\n NEW.is_approved IS TRUE\n AND NEW.is_verified IS TRUE\n AND NEW.is_disabled IS FALSE INTO NEW.is_active;\n \n RETURN NEW;\nEND$$",
"plpgsql_deparser_fixes-14.sql": "-- Test 14: Procedure (implicit void return)\nCREATE PROCEDURE test_procedure(p_message text)\nLANGUAGE plpgsql AS $$\nBEGIN\n RAISE NOTICE '%', p_message;\nEND$$",
"plpgsql_control-1.sql": "--\n-- Tests for PL/pgSQL control structures\n--\n\n-- integer FOR loop\n\ndo $$\nbegin\n -- basic case\n for i in 1..3 loop\n raise notice '1..3: i = %', i;\n end loop;\n -- with BY, end matches exactly\n for i in 1..10 by 3 loop\n raise notice '1..10 by 3: i = %', i;\n end loop;\n -- with BY, end does not match\n for i in 1..11 by 3 loop\n raise notice '1..11 by 3: i = %', i;\n end loop;\n -- zero iterations\n for i in 1..0 by 3 loop\n raise notice '1..0 by 3: i = %', i;\n end loop;\n -- REVERSE\n for i in reverse 10..0 by 3 loop\n raise notice 'reverse 10..0 by 3: i = %', i;\n end loop;\n -- potential overflow\n for i in 2147483620..2147483647 by 10 loop\n raise notice '2147483620..2147483647 by 10: i = %', i;\n end loop;\n -- potential overflow, reverse direction\n for i in reverse -2147483620..-2147483647 by 10 loop\n raise notice 'reverse -2147483620..-2147483647 by 10: i = %', i;\n end loop;\nend$$",
"plpgsql_control-2.sql": "-- BY can't be zero or negative\ndo $$\nbegin\n for i in 1..3 by 0 loop\n raise notice '1..3 by 0: i = %', i;\n end loop;\nend$$",
"plpgsql_control-3.sql": "do $$\nbegin\n for i in 1..3 by -1 loop\n raise notice '1..3 by -1: i = %', i;\n end loop;\nend$$",
Expand Down
164 changes: 164 additions & 0 deletions __fixtures__/plpgsql/plpgsql_deparser_fixes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
-- Fixtures to test deparser fixes from constructive-db PR #229
-- These exercise: PERFORM, INTO clause placement, record field qualification, RETURN handling

-- Test 1: PERFORM statement (parser stores as SELECT, deparser must strip SELECT)
CREATE FUNCTION test_perform_basic() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM pg_notify('test_channel', 'message');
RETURN NEW;
END$$;

-- Test 2: PERFORM with function call and arguments
CREATE FUNCTION test_perform_with_args() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
PERFORM pg_notify(TG_ARGV[0], to_json(NEW)::text);
RETURN NEW;
END IF;
IF (TG_OP = 'DELETE') THEN
PERFORM pg_notify(TG_ARGV[0], to_json(OLD)::text);
RETURN OLD;
END IF;
RETURN NULL;
END$$;

-- Test 3: INTO clause with record field target (recfield qualification)
CREATE FUNCTION test_into_record_field() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
SELECT
NEW.is_approved IS TRUE
AND NEW.is_verified IS TRUE
AND NEW.is_disabled IS FALSE INTO NEW.is_active;
RETURN NEW;
END$$;

-- Test 4: INTO clause with subquery (depth-aware scanner must skip nested FROM)
CREATE FUNCTION test_into_with_subquery() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
result_value int;
BEGIN
SELECT count(*) INTO result_value
FROM (SELECT id FROM users WHERE id = NEW.user_id) sub;
RETURN NEW;
END$$;

-- Test 5: INTO clause with multiple record fields
CREATE FUNCTION test_into_multiple_fields() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
SELECT is_active, is_verified INTO NEW.is_active, NEW.is_verified
FROM users WHERE id = NEW.user_id;
RETURN NEW;
END$$;

-- Test 6: SETOF function with RETURN QUERY and bare RETURN
CREATE FUNCTION test_setof_return_query(p_limit int)
RETURNS SETOF int
LANGUAGE plpgsql AS $$
BEGIN
RETURN QUERY SELECT generate_series(1, p_limit);
RETURN;
END$$;

-- Test 7: SETOF function with RETURN NEXT
CREATE FUNCTION test_setof_return_next(p_count int)
RETURNS SETOF text
LANGUAGE plpgsql AS $$
DECLARE
i int;
BEGIN
FOR i IN 1..p_count LOOP
RETURN NEXT 'item_' || i::text;
END LOOP;
RETURN;
END$$;

-- Test 8: Void function with bare RETURN
CREATE FUNCTION test_void_function(p_value text)
RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
RAISE NOTICE 'Value: %', p_value;
RETURN;
END$$;

-- Test 9: Scalar function with RETURN NULL
CREATE FUNCTION test_scalar_return_null()
RETURNS int
LANGUAGE plpgsql AS $$
BEGIN
RETURN NULL;
END$$;

-- Test 10: Scalar function with conditional RETURN
CREATE FUNCTION test_scalar_conditional(p_value int)
RETURNS int
LANGUAGE plpgsql AS $$
BEGIN
IF p_value > 0 THEN
RETURN p_value * 2;
END IF;
RETURN NULL;
END$$;

-- Test 11: OUT parameter function with bare RETURN
CREATE FUNCTION test_out_params(OUT ok boolean, OUT message text)
LANGUAGE plpgsql AS $$
BEGIN
ok := true;
message := 'success';
RETURN;
END$$;

-- Test 12: RETURNS TABLE function with RETURN QUERY
CREATE FUNCTION test_returns_table(p_prefix text)
RETURNS TABLE(id int, name text)
LANGUAGE plpgsql AS $$
BEGIN
RETURN QUERY SELECT 1, p_prefix || '_one';
RETURN QUERY SELECT 2, p_prefix || '_two';
RETURN;
END$$;

-- Test 13: Trigger function with complex logic
CREATE FUNCTION test_trigger_complex() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
defaults_record record;
bit_len int;
BEGIN
bit_len := bit_length(NEW.permissions);

SELECT * INTO defaults_record
FROM permission_defaults AS t
LIMIT 1;

IF found THEN
NEW.is_approved := defaults_record.is_approved;
NEW.is_verified := defaults_record.is_verified;
END IF;

IF NEW.is_owner IS TRUE THEN
NEW.is_admin := true;
NEW.is_approved := true;
NEW.is_verified := true;
END IF;

SELECT
NEW.is_approved IS TRUE
AND NEW.is_verified IS TRUE
AND NEW.is_disabled IS FALSE INTO NEW.is_active;

RETURN NEW;
END$$;

-- Test 14: Procedure (implicit void return)
CREATE PROCEDURE test_procedure(p_message text)
LANGUAGE plpgsql AS $$
BEGIN
RAISE NOTICE '%', p_message;
END$$;
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ BEGIN
RAISE EXCEPTION 'p_round_to out of range: %', p_round_to;
END IF;
IF p_lock THEN
PERFORM SELECT pg_advisory_xact_lock(v_lock_key);
PERFORM pg_advisory_xact_lock(v_lock_key);
END IF;
IF p_debug THEN
RAISE NOTICE 'big_kitchen_sink start=% org=% user=% from=% to=% min_total=%', v_now, p_org_id, p_user_id, p_from_ts, p_to_ts, v_min_total;
Expand Down Expand Up @@ -99,7 +99,7 @@ BEGIN
SELECT
t.orders_scanned,
t.gross_total,
t.avg_total
t.avg_total INTO v_orders_scanned, v_gross, v_avg
FROM totals AS t;
IF p_apply_discount THEN
v_rebate := round(v_gross * GREATEST(LEAST(v_discount_rate + v_jitter, 0.50), 0), p_round_to);
Expand All @@ -110,7 +110,7 @@ BEGIN
v_net := round(((v_gross - v_discount) + v_tax) * power(10::numeric, 0), p_round_to);
SELECT
oi.sku,
CAST(sum(oi.quantity) AS bigint) AS qty
CAST(sum(oi.quantity) AS bigint) AS qty INTO v_top_sku, v_top_sku_qty
FROM app_public.order_item AS oi
JOIN app_public.app_order AS o ON o.id = oi.order_id
WHERE
Expand Down
68 changes: 64 additions & 4 deletions packages/plpgsql-deparser/__tests__/plpgsql-deparser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,74 @@ describe('PLpgSQLDeparser', () => {
});

describe('round-trip tests using generated.json', () => {
it('should round-trip plpgsql_domain fixtures', async () => {
const entries = fixtureTestUtils.getTestEntries(['plpgsql_domain']);
// Known failing fixtures due to pre-existing deparser issues:
// - Schema qualification loss (pg_catalog.pg_class%rowtype[] -> pg_class%rowtype[])
// - Tagged dollar quote reconstruction ($tag$...$tag$ not supported)
// - Exception block handling issues
// TODO: Fix these underlying issues and remove from allowlist
const KNOWN_FAILING_FIXTURES = new Set([
'plpgsql_varprops-13.sql',
'plpgsql_trap-1.sql',
'plpgsql_trap-2.sql',
'plpgsql_trap-3.sql',
'plpgsql_trap-4.sql',
'plpgsql_trap-5.sql',
'plpgsql_trap-6.sql',
'plpgsql_trap-7.sql',
'plpgsql_transaction-17.sql',
'plpgsql_transaction-19.sql',
'plpgsql_transaction-20.sql',
'plpgsql_transaction-21.sql',
'plpgsql_control-15.sql',
'plpgsql_control-17.sql',
'plpgsql_call-44.sql',
'plpgsql_array-20.sql',
]);

it('should round-trip ALL generated fixtures (excluding known failures)', async () => {
// Get all fixtures without any filter - this ensures we test everything
const entries = fixtureTestUtils.getTestEntries();
expect(entries.length).toBeGreaterThan(0);

const failures: { key: string; error: string }[] = [];
const unexpectedPasses: string[] = [];

for (const [key] of entries) {
await fixtureTestUtils.runSingleFixture(key);
const isKnownFailing = KNOWN_FAILING_FIXTURES.has(key);
try {
await fixtureTestUtils.runSingleFixture(key);
if (isKnownFailing) {
unexpectedPasses.push(key);
}
} catch (err) {
if (!isKnownFailing) {
failures.push({
key,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
});

// Report unexpected passes (fixtures that should be removed from allowlist)
if (unexpectedPasses.length > 0) {
console.log(`\nUnexpected passes (remove from KNOWN_FAILING_FIXTURES):\n${unexpectedPasses.join('\n')}`);
}

// Fail if any non-allowlisted fixtures fail (regression detection)
if (failures.length > 0) {
const failureReport = failures
.map(f => ` - ${f.key}: ${f.error}`)
.join('\n');
throw new Error(
`${failures.length} NEW fixture failures (not in allowlist):\n${failureReport}`
);
}

// Report coverage stats
const testedCount = entries.length - KNOWN_FAILING_FIXTURES.size;
console.log(`\nRound-trip tested ${testedCount} of ${entries.length} fixtures (${KNOWN_FAILING_FIXTURES.size} known failures skipped)`);
}, 120000); // 2 minute timeout for all fixtures
});

describe('PLpgSQLDeparser class', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ begin
raise exception 'p_round_to out of range: %', p_round_to;
end if;
if p_lock then
perform SELECT pg_advisory_xact_lock(v_lock_key);
perform pg_advisory_xact_lock(v_lock_key);
end if;
if p_debug then
raise notice 'big_kitchen_sink start=% org=% user=% from=% to=% min_total=%', v_now, p_org_id, p_user_id, p_from_ts, p_to_ts, v_min_total;
Expand Down Expand Up @@ -67,7 +67,7 @@ begin
SELECT
t.orders_scanned,
t.gross_total,
t.avg_total
t.avg_total into v_orders_scanned, v_gross, v_avg
FROM totals t;
if p_apply_discount then
v_discount := round(v_gross * GREATEST(LEAST(v_discount_rate + v_jitter, 0.50), 0), p_round_to);
Expand All @@ -78,7 +78,7 @@ begin
v_net := round((v_gross - v_discount + v_tax) * power(10::numeric, 0), p_round_to);
SELECT
oi.sku,
sum(oi.quantity)::bigint AS qty
sum(oi.quantity)::bigint AS qty into v_top_sku, v_top_sku_qty
FROM app_public.order_item oi
JOIN app_public.app_order o ON o.id = oi.order_id
WHERE o.org_id = p_org_id
Expand Down Expand Up @@ -237,7 +237,7 @@ BEGIN
RAISE EXCEPTION 'p_round_to out of range: %', p_round_to;
END IF;
IF p_lock THEN
PERFORM SELECT pg_advisory_xact_lock(v_lock_key);
PERFORM pg_advisory_xact_lock(v_lock_key);
END IF;
IF p_debug THEN
RAISE NOTICE 'big_kitchen_sink start=% org=% user=% from=% to=% min_total=%', v_now, p_org_id, p_user_id, p_from_ts, p_to_ts, v_min_total;
Expand Down Expand Up @@ -268,7 +268,7 @@ BEGIN
SELECT
t.orders_scanned,
t.gross_total,
t.avg_total
t.avg_total INTO v_orders_scanned, v_gross, v_avg
FROM totals t;
IF p_apply_discount THEN
v_discount := round(v_gross * GREATEST(LEAST(v_discount_rate + v_jitter, 0.50), 0), p_round_to);
Expand All @@ -279,7 +279,7 @@ BEGIN
v_net := round((v_gross - v_discount + v_tax) * power(10::numeric, 0), p_round_to);
SELECT
oi.sku,
sum(oi.quantity)::bigint AS qty
sum(oi.quantity)::bigint AS qty INTO v_top_sku, v_top_sku_qty
FROM app_public.order_item oi
JOIN app_public.app_order o ON o.id = oi.order_id
WHERE o.org_id = p_org_id
Expand Down
Loading