diff --git a/__fixtures__/plpgsql-generated/generated.json b/__fixtures__/plpgsql-generated/generated.json index 51f1469d..0130d1f2 100644 --- a/__fixtures__/plpgsql-generated/generated.json +++ b/__fixtures__/plpgsql-generated/generated.json @@ -155,6 +155,10 @@ "plpgsql_deparser_fixes-54.sql": "-- Test 54: Bound cursor with explicit arguments (must emit the parameter list)\nCREATE FUNCTION test_bound_cursor_args() RETURNS void\nLANGUAGE plpgsql AS $$\nDECLARE\n c CURSOR (key int, label text) FOR SELECT * FROM users WHERE id = key AND name = label;\n r record;\nBEGIN\n OPEN c(42, 'x');\n FETCH c INTO r;\n CLOSE c;\nEND$$", "plpgsql_deparser_fixes-55.sql": "-- Test 55: RAISE with a SQLSTATE condition code (must emit SQLSTATE 'xxxxx', not a bare number)\nCREATE FUNCTION test_raise_sqlstate() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n RAISE SQLSTATE '22012';\nEND$$", "plpgsql_deparser_fixes-56.sql": "-- Test 56: RAISE EXCEPTION with a named condition (must stay a bare identifier)\nCREATE FUNCTION test_raise_named_condition() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n RAISE EXCEPTION division_by_zero;\nEND$$", + "plpgsql_deparser_fixes-57.sql": "-- Test 57: Bare RETURN NEXT with OUT parameters (retvarno points at out_param_varno; must stay bare)\nCREATE FUNCTION test_return_next_out_params(OUT x integer, OUT y text) RETURNS SETOF record\nLANGUAGE plpgsql AS $$\nBEGIN\n FOR i IN 1..5 LOOP\n x := i;\n y := 'item_' || i::text;\n RETURN NEXT;\n END LOOP;\n RETURN;\nEND$$", + "plpgsql_deparser_fixes-58.sql": "-- Test 58: RETURN NEXT with a variable (retvarno must be emitted as the variable name)\nCREATE FUNCTION test_return_next_var() RETURNS SETOF integer\nLANGUAGE plpgsql AS $$\nDECLARE\n r integer;\nBEGIN\n FOR r IN SELECT g FROM generate_series(1, 3) g LOOP\n RETURN NEXT r;\n END LOOP;\nEND$$", + "plpgsql_deparser_fixes-59.sql": "-- Test 59: Top-level block with EXCEPTION clause (compiler wraps it in a synthetic outer block; must not deparse a nested BEGIN)\nCREATE FUNCTION test_toplevel_exception(a numeric, b numeric) RETURNS numeric\nLANGUAGE plpgsql AS $$\nDECLARE\n v_result numeric;\nBEGIN\n v_result := a / b;\n RETURN v_result;\nEXCEPTION\n WHEN division_by_zero THEN\n RETURN NULL;\nEND$$", + "plpgsql_deparser_fixes-60.sql": "-- Test 60: Explicit nested block with EXCEPTION inside a top-level block (nesting must be preserved)\nCREATE FUNCTION test_explicit_nested_exception(p_id integer) RETURNS text\nLANGUAGE plpgsql AS $$\nDECLARE\n v_result text;\nBEGIN\n v_result := 'unknown';\n BEGIN\n SELECT status INTO v_result FROM items WHERE id = p_id;\n EXCEPTION\n WHEN no_data_found THEN\n v_result := 'not_found';\n END;\n RETURN v_result;\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$$", diff --git a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql index e94dd2aa..7846b1c1 100644 --- a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql +++ b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql @@ -702,3 +702,55 @@ LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION division_by_zero; END$$; + +-- Test 57: Bare RETURN NEXT with OUT parameters (retvarno points at out_param_varno; must stay bare) +CREATE FUNCTION test_return_next_out_params(OUT x integer, OUT y text) RETURNS SETOF record +LANGUAGE plpgsql AS $$ +BEGIN + FOR i IN 1..5 LOOP + x := i; + y := 'item_' || i::text; + RETURN NEXT; + END LOOP; + RETURN; +END$$; + +-- Test 58: RETURN NEXT with a variable (retvarno must be emitted as the variable name) +CREATE FUNCTION test_return_next_var() RETURNS SETOF integer +LANGUAGE plpgsql AS $$ +DECLARE + r integer; +BEGIN + FOR r IN SELECT g FROM generate_series(1, 3) g LOOP + RETURN NEXT r; + END LOOP; +END$$; + +-- Test 59: Top-level block with EXCEPTION clause (compiler wraps it in a synthetic outer block; must not deparse a nested BEGIN) +CREATE FUNCTION test_toplevel_exception(a numeric, b numeric) RETURNS numeric +LANGUAGE plpgsql AS $$ +DECLARE + v_result numeric; +BEGIN + v_result := a / b; + RETURN v_result; +EXCEPTION + WHEN division_by_zero THEN + RETURN NULL; +END$$; + +-- Test 60: Explicit nested block with EXCEPTION inside a top-level block (nesting must be preserved) +CREATE FUNCTION test_explicit_nested_exception(p_id integer) RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + v_result text; +BEGIN + v_result := 'unknown'; + BEGIN + SELECT status INTO v_result FROM items WHERE id = p_id; + EXCEPTION + WHEN no_data_found THEN + v_result := 'not_found'; + END; + RETURN v_result; +END$$; diff --git a/packages/parse/package.json b/packages/parse/package.json index 8408e6ef..3acb0abc 100644 --- a/packages/parse/package.json +++ b/packages/parse/package.json @@ -40,7 +40,7 @@ "round-trip" ], "dependencies": { - "@libpg-query/parser": "^17.6.10", + "@libpg-query/parser": "^17.8.0", "@pgsql/types": "^17.6.2", "pgsql-deparser": "workspace:*" }, diff --git a/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap b/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap index 9807d5d1..4ad5cc0f 100644 --- a/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap @@ -238,25 +238,21 @@ END" exports[`plpgsql-deparser bug fixes SQLSTATE exception conditions should emit SQLSTATE codes with the SQLSTATE keyword 1`] = ` "BEGIN - BEGIN - RETURN 1; - EXCEPTION - WHEN unique_violation OR SQLSTATE '23503' THEN - RETURN -1; - WHEN SQLSTATE 'P0001' THEN - RETURN -2; - END; + RETURN 1; +EXCEPTION + WHEN unique_violation OR SQLSTATE '23503' THEN + RETURN -1; + WHEN SQLSTATE 'P0001' THEN + RETURN -2; END" `; exports[`plpgsql-deparser bug fixes bare RAISE re-throw should keep a bare RAISE bare (not RAISE EXCEPTION;) 1`] = ` "BEGIN - BEGIN - PERFORM 1; - EXCEPTION - WHEN others THEN - RAISE; - END; + PERFORM 1; +EXCEPTION + WHEN others THEN + RAISE; END" `; @@ -370,17 +366,15 @@ END" exports[`plpgsql-deparser bug fixes deep nesting and sequential blocks should handle block inside exception handler 1`] = ` "BEGIN - BEGIN - PERFORM risky(); - EXCEPTION - WHEN others THEN - BEGIN - PERFORM log_error(); - EXCEPTION - WHEN others THEN - RAISE NOTICE 'even logging failed'; - END; - END; + PERFORM risky(); +EXCEPTION + WHEN others THEN + BEGIN + PERFORM log_error(); + EXCEPTION + WHEN others THEN + RAISE NOTICE 'even logging failed'; + END; END" `; @@ -548,6 +542,33 @@ BEGIN END" `; +exports[`plpgsql-deparser bug fixes top-level EXCEPTION wrapper block should not emit a nested BEGIN for a top-level block with EXCEPTION 1`] = ` +"DECLARE + v_result numeric; +BEGIN + v_result := a / b; + RETURN v_result; +EXCEPTION + WHEN division_by_zero THEN + RETURN NULL; +END" +`; + +exports[`plpgsql-deparser bug fixes top-level EXCEPTION wrapper block should preserve an explicit nested block with EXCEPTION 1`] = ` +"DECLARE + v_result text; +BEGIN + v_result := 'unknown'; + BEGIN + SELECT status INTO v_result FROM items WHERE id = p_id; + EXCEPTION + WHEN no_data_found THEN + v_result := 'not_found'; + END; + RETURN v_result; +END" +`; + exports[`plpgsql-deparser bug fixes untested statement types should handle ASSERT statement 1`] = ` "BEGIN ASSERT p_x > 0, 'x must be positive'; diff --git a/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap b/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap index ed1f089e..1d3eeb74 100644 --- a/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap @@ -50,139 +50,137 @@ exports[`hydrate demonstration with big-function.sql should parse, hydrate, modi v_rowcount int := 0; v_lock_key bigint := CAST(CAST('x' || substr(md5(p_org_id::text), 1, 16) AS pg_catalog.bit(64)) AS bigint); BEGIN - BEGIN - IF p_org_id IS NULL - OR p_user_id IS NULL THEN - RAISE EXCEPTION 'p_org_id and p_user_id are required'; - END IF; - IF p_from_ts > p_to_ts THEN - RAISE EXCEPTION 'p_from_ts (%) must be <= p_to_ts (%)', p_from_ts, p_to_ts; - END IF; - IF p_max_rows < 1 - OR p_max_rows > 10000 THEN - RAISE EXCEPTION 'p_max_rows out of range: %', p_max_rows; - END IF; - IF p_round_to < 0 - OR p_round_to > 6 THEN - RAISE EXCEPTION 'p_round_to out of range: %', p_round_to; - END IF; - IF p_lock THEN - 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; - END IF; - WITH - base AS (SELECT - o.id, - o.total_amount::numeric AS total_amount, - o.currency, - o.created_at - FROM app_public.app_order AS o - WHERE - o.org_id = p_org_id - AND o.user_id = p_user_id - AND o.created_at >= p_from_ts - AND o.created_at < p_to_ts - AND o.total_amount::numeric >= v_min_total - AND o.currency = p_currency - ORDER BY - o.created_at DESC - LIMIT p_max_rows), - totals AS (SELECT - (count(*))::int AS orders_scanned, - COALESCE(sum(total_amount), 0) AS gross_total, - COALESCE(avg(total_amount), 0) AS avg_total - FROM base) - SELECT - t.orders_scanned, - t.gross_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); - ELSE - v_discount := 0; - END IF; - v_levy := round(GREATEST(v_gross - v_discount, 0) * v_tax_rate, p_round_to); - 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 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 - o.org_id = p_org_id - AND o.user_id = p_user_id - AND o.created_at >= p_from_ts - AND o.created_at < p_to_ts - AND o.currency = p_currency - GROUP BY - oi.sku - ORDER BY - qty DESC, - oi.sku ASC - LIMIT 1; - INSERT INTO app_public.order_rollup ( - org_id, - user_id, - period_from, - period_to, - currency, - orders_scanned, - gross_total, - discount_total, - tax_total, - net_total, - avg_order_total, - top_sku, - top_sku_qty, - note, - updated_at - ) VALUES - (p_org_id, p_user_id, p_from_ts, p_to_ts, p_currency, v_orders_scanned, v_gross, v_discount, v_tax, v_net, v_avg, v_top_sku, v_top_sku_qty, p_note, now()) ON CONFLICT (org_id, user_id, period_from, period_to, currency) DO UPDATE SET - orders_scanned = excluded.orders_scanned, - gross_total = excluded.gross_total, - discount_total = excluded.discount_total, - tax_total = excluded.tax_total, - net_total = excluded.net_total, - avg_order_total = excluded.avg_order_total, - top_sku = excluded.top_sku, - top_sku_qty = excluded.top_sku_qty, - note = COALESCE(excluded.note, app_public.order_rollup.note), - updated_at = now(); - GET DIAGNOSTICS v_rowcount = ROW_COUNT; - v_orders_upserted := v_rowcount; - v_sql := format('SELECT count(*)::int FROM %I.%I WHERE org_id = $1 AND created_at >= $2 AND created_at < $3', 'app_public', 'app_order'); - EXECUTE v_sql INTO v_rowcount USING p_org_id, p_from_ts, p_to_ts; - IF p_debug THEN - RAISE NOTICE 'dynamic count(app_order)=%', v_rowcount; - END IF; - org_id := p_org_id; - user_id := p_user_id; - period_from := p_from_ts; - period_to := p_to_ts; - orders_scanned := v_orders_scanned; - orders_upserted := v_orders_upserted; - gross_total := v_gross; - discount_total := v_discount; - tax_total := v_tax; - net_total := v_net; - avg_order_total := round(v_avg, p_round_to); - top_sku := v_top_sku; - top_sku_qty := v_top_sku_qty; - message := format('rollup ok: gross=%s discount=%s tax=%s net=%s (discount_rate=%s tax_rate=%s)', v_gross, v_discount, v_tax, v_net, v_discount_rate, v_tax_rate); - RETURN NEXT; - RETURN; - EXCEPTION - WHEN unique_violation THEN - RAISE NOTICE 'unique_violation: %', sqlerrm; - RAISE; - WHEN others THEN - IF p_debug THEN - RAISE NOTICE 'error: % (%:%)', sqlerrm, sqlstate, sqlerrm; - END IF; - RAISE; - END; + IF p_org_id IS NULL + OR p_user_id IS NULL THEN + RAISE EXCEPTION 'p_org_id and p_user_id are required'; + END IF; + IF p_from_ts > p_to_ts THEN + RAISE EXCEPTION 'p_from_ts (%) must be <= p_to_ts (%)', p_from_ts, p_to_ts; + END IF; + IF p_max_rows < 1 + OR p_max_rows > 10000 THEN + RAISE EXCEPTION 'p_max_rows out of range: %', p_max_rows; + END IF; + IF p_round_to < 0 + OR p_round_to > 6 THEN + RAISE EXCEPTION 'p_round_to out of range: %', p_round_to; + END IF; + IF p_lock THEN + 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; + END IF; + WITH + base AS (SELECT + o.id, + o.total_amount::numeric AS total_amount, + o.currency, + o.created_at + FROM app_public.app_order AS o + WHERE + o.org_id = p_org_id + AND o.user_id = p_user_id + AND o.created_at >= p_from_ts + AND o.created_at < p_to_ts + AND o.total_amount::numeric >= v_min_total + AND o.currency = p_currency + ORDER BY + o.created_at DESC + LIMIT p_max_rows), + totals AS (SELECT + (count(*))::int AS orders_scanned, + COALESCE(sum(total_amount), 0) AS gross_total, + COALESCE(avg(total_amount), 0) AS avg_total + FROM base) + SELECT + t.orders_scanned, + t.gross_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); + ELSE + v_discount := 0; + END IF; + v_levy := round(GREATEST(v_gross - v_discount, 0) * v_tax_rate, p_round_to); + 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 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 + o.org_id = p_org_id + AND o.user_id = p_user_id + AND o.created_at >= p_from_ts + AND o.created_at < p_to_ts + AND o.currency = p_currency + GROUP BY + oi.sku + ORDER BY + qty DESC, + oi.sku ASC + LIMIT 1; + INSERT INTO app_public.order_rollup ( + org_id, + user_id, + period_from, + period_to, + currency, + orders_scanned, + gross_total, + discount_total, + tax_total, + net_total, + avg_order_total, + top_sku, + top_sku_qty, + note, + updated_at + ) VALUES + (p_org_id, p_user_id, p_from_ts, p_to_ts, p_currency, v_orders_scanned, v_gross, v_discount, v_tax, v_net, v_avg, v_top_sku, v_top_sku_qty, p_note, now()) ON CONFLICT (org_id, user_id, period_from, period_to, currency) DO UPDATE SET + orders_scanned = excluded.orders_scanned, + gross_total = excluded.gross_total, + discount_total = excluded.discount_total, + tax_total = excluded.tax_total, + net_total = excluded.net_total, + avg_order_total = excluded.avg_order_total, + top_sku = excluded.top_sku, + top_sku_qty = excluded.top_sku_qty, + note = COALESCE(excluded.note, app_public.order_rollup.note), + updated_at = now(); + GET DIAGNOSTICS v_rowcount = ROW_COUNT; + v_orders_upserted := v_rowcount; + v_sql := format('SELECT count(*)::int FROM %I.%I WHERE org_id = $1 AND created_at >= $2 AND created_at < $3', 'app_public', 'app_order'); + EXECUTE v_sql INTO v_rowcount USING p_org_id, p_from_ts, p_to_ts; + IF p_debug THEN + RAISE NOTICE 'dynamic count(app_order)=%', v_rowcount; + END IF; + org_id := p_org_id; + user_id := p_user_id; + period_from := p_from_ts; + period_to := p_to_ts; + orders_scanned := v_orders_scanned; + orders_upserted := v_orders_upserted; + gross_total := v_gross; + discount_total := v_discount; + tax_total := v_tax; + net_total := v_net; + avg_order_total := round(v_avg, p_round_to); + top_sku := v_top_sku; + top_sku_qty := v_top_sku_qty; + message := format('rollup ok: gross=%s discount=%s tax=%s net=%s (discount_rate=%s tax_rate=%s)', v_gross, v_discount, v_tax, v_net, v_discount_rate, v_tax_rate); + RETURN NEXT; + RETURN; +EXCEPTION + WHEN unique_violation THEN + RAISE NOTICE 'unique_violation: %', sqlerrm; + RAISE; + WHEN others THEN + IF p_debug THEN + RAISE NOTICE 'error: % (%:%)', sqlerrm, sqlstate, sqlerrm; + END IF; + RAISE; END$$" `; diff --git a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts index f473dbc8..35db7f75 100644 --- a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts +++ b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts @@ -1297,4 +1297,52 @@ END$$`; expect(deparsed).not.toMatch(/SQLSTATE/i); }); }); + + describe('top-level EXCEPTION wrapper block', () => { + it('should not emit a nested BEGIN for a top-level block with EXCEPTION', async () => { + const sql = `CREATE FUNCTION test_toplevel_exception(a numeric, b numeric) RETURNS numeric +LANGUAGE plpgsql AS $$ +DECLARE + v_result numeric; +BEGIN + v_result := a / b; + RETURN v_result; +EXCEPTION + WHEN division_by_zero THEN + RETURN NULL; +END$$`; + + await testUtils.expectAstMatch('top-level exception block', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).not.toMatch(/BEGIN\s+BEGIN/i); + }); + + it('should preserve an explicit nested block with EXCEPTION', async () => { + const sql = `CREATE FUNCTION test_explicit_nested_exception(p_id integer) RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + v_result text; +BEGIN + v_result := 'unknown'; + BEGIN + SELECT status INTO v_result FROM items WHERE id = p_id; + EXCEPTION + WHEN no_data_found THEN + v_result := 'not_found'; + END; + RETURN v_result; +END$$`; + + await testUtils.expectAstMatch('explicit nested exception block', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + const beginCount = (deparsed.match(/BEGIN/g) ?? []).length; + expect(beginCount).toBe(2); + }); + }); }); diff --git a/packages/plpgsql-deparser/__tests__/pretty/__snapshots__/plpgsql-pretty.test.ts.snap b/packages/plpgsql-deparser/__tests__/pretty/__snapshots__/plpgsql-pretty.test.ts.snap index 1a87ba39..0cea92a5 100644 --- a/packages/plpgsql-deparser/__tests__/pretty/__snapshots__/plpgsql-pretty.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/pretty/__snapshots__/plpgsql-pretty.test.ts.snap @@ -20,159 +20,157 @@ exports[`lowercase: big-function.sql 1`] = ` v_rowcount int := 0; v_lock_key bigint := ('x' || substr(md5(p_org_id::text), 1, 16))::bit(64)::bigint; begin - begin - if p_org_id IS NULL OR p_user_id IS NULL then - raise exception 'p_org_id and p_user_id are required'; - end if; - if p_from_ts > p_to_ts then - raise exception 'p_from_ts (%) must be <= p_to_ts (%)', p_from_ts, p_to_ts; - end if; - if p_max_rows < 1 OR p_max_rows > 10000 then - raise exception 'p_max_rows out of range: %', p_max_rows; - end if; - if p_round_to < 0 OR p_round_to > 6 then - raise exception 'p_round_to out of range: %', p_round_to; - end if; - if p_lock then - 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; - end if; - WITH base AS ( - SELECT - o.id, - o.total_amount::numeric AS total_amount, - o.currency, - o.created_at - FROM app_public.app_order o - WHERE o.org_id = p_org_id - AND o.user_id = p_user_id - AND o.created_at >= p_from_ts - AND o.created_at < p_to_ts - AND o.total_amount::numeric >= v_min_total - AND o.currency = p_currency - ORDER BY o.created_at DESC - LIMIT p_max_rows - ), - totals AS ( - SELECT - count(*)::int AS orders_scanned, - COALESCE(sum(total_amount), 0) AS gross_total, - COALESCE(avg(total_amount), 0) AS avg_total - FROM base - ) - SELECT - t.orders_scanned, - t.gross_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); - else - v_discount := 0; - end if; - v_tax := round(GREATEST(v_gross - v_discount, 0) * v_tax_rate, p_round_to); - v_net := round((v_gross - v_discount + v_tax) * power(10::numeric, 0), p_round_to); + if p_org_id IS NULL OR p_user_id IS NULL then + raise exception 'p_org_id and p_user_id are required'; + end if; + if p_from_ts > p_to_ts then + raise exception 'p_from_ts (%) must be <= p_to_ts (%)', p_from_ts, p_to_ts; + end if; + if p_max_rows < 1 OR p_max_rows > 10000 then + raise exception 'p_max_rows out of range: %', p_max_rows; + end if; + if p_round_to < 0 OR p_round_to > 6 then + raise exception 'p_round_to out of range: %', p_round_to; + end if; + if p_lock then + 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; + end if; + WITH base AS ( SELECT - oi.sku, - 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 - AND o.user_id = p_user_id - AND o.created_at >= p_from_ts - AND o.created_at < p_to_ts - AND o.currency = p_currency - GROUP BY oi.sku - ORDER BY qty DESC, oi.sku ASC - LIMIT 1; - INSERT INTO app_public.order_rollup ( - org_id, - user_id, - period_from, - period_to, - currency, - orders_scanned, - gross_total, - discount_total, - tax_total, - net_total, - avg_order_total, - top_sku, - top_sku_qty, - note, - updated_at - ) - VALUES ( - p_org_id, - p_user_id, - p_from_ts, - p_to_ts, - p_currency, - v_orders_scanned, - v_gross, - v_discount, - v_tax, - v_net, - v_avg, - v_top_sku, - v_top_sku_qty, - p_note, - now() - ) - ON CONFLICT (org_id, user_id, period_from, period_to, currency) - DO UPDATE SET - orders_scanned = EXCLUDED.orders_scanned, - gross_total = EXCLUDED.gross_total, - discount_total = EXCLUDED.discount_total, - tax_total = EXCLUDED.tax_total, - net_total = EXCLUDED.net_total, - avg_order_total = EXCLUDED.avg_order_total, - top_sku = EXCLUDED.top_sku, - top_sku_qty = EXCLUDED.top_sku_qty, - note = COALESCE(EXCLUDED.note, app_public.order_rollup.note), - updated_at = now(); - get diagnostics v_rowcount = row_count; - v_orders_upserted := v_rowcount; - v_sql := format( - 'SELECT count(*)::int FROM %I.%I WHERE org_id = $1 AND created_at >= $2 AND created_at < $3', - 'app_public', - 'app_order' - ); - execute v_sql into v_rowcount using p_org_id, p_from_ts, p_to_ts; - if p_debug then - raise notice 'dynamic count(app_order)=%', v_rowcount; - end if; - org_id := p_org_id; - user_id := p_user_id; - period_from := p_from_ts; - period_to := p_to_ts; - orders_scanned := v_orders_scanned; - orders_upserted := v_orders_upserted; - gross_total := v_gross; - discount_total := v_discount; - tax_total := v_tax; - net_total := v_net; - avg_order_total := round(v_avg, p_round_to); - top_sku := v_top_sku; - top_sku_qty := v_top_sku_qty; - message := format( - 'rollup ok: gross=%s discount=%s tax=%s net=%s (discount_rate=%s tax_rate=%s)', - v_gross, v_discount, v_tax, v_net, v_discount_rate, v_tax_rate - ); - return next; - return; - exception - when unique_violation then - raise notice 'unique_violation: %', SQLERRM; - raise; - when others then - if p_debug then - raise notice 'error: % (%:%)', SQLERRM, SQLSTATE, SQLERRM; - end if; - raise; - end; + o.id, + o.total_amount::numeric AS total_amount, + o.currency, + o.created_at + FROM app_public.app_order o + WHERE o.org_id = p_org_id + AND o.user_id = p_user_id + AND o.created_at >= p_from_ts + AND o.created_at < p_to_ts + AND o.total_amount::numeric >= v_min_total + AND o.currency = p_currency + ORDER BY o.created_at DESC + LIMIT p_max_rows + ), + totals AS ( + SELECT + count(*)::int AS orders_scanned, + COALESCE(sum(total_amount), 0) AS gross_total, + COALESCE(avg(total_amount), 0) AS avg_total + FROM base + ) + SELECT + t.orders_scanned, + t.gross_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); + else + v_discount := 0; + end if; + v_tax := round(GREATEST(v_gross - v_discount, 0) * v_tax_rate, p_round_to); + 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 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 + AND o.user_id = p_user_id + AND o.created_at >= p_from_ts + AND o.created_at < p_to_ts + AND o.currency = p_currency + GROUP BY oi.sku + ORDER BY qty DESC, oi.sku ASC + LIMIT 1; + INSERT INTO app_public.order_rollup ( + org_id, + user_id, + period_from, + period_to, + currency, + orders_scanned, + gross_total, + discount_total, + tax_total, + net_total, + avg_order_total, + top_sku, + top_sku_qty, + note, + updated_at + ) + VALUES ( + p_org_id, + p_user_id, + p_from_ts, + p_to_ts, + p_currency, + v_orders_scanned, + v_gross, + v_discount, + v_tax, + v_net, + v_avg, + v_top_sku, + v_top_sku_qty, + p_note, + now() + ) + ON CONFLICT (org_id, user_id, period_from, period_to, currency) + DO UPDATE SET + orders_scanned = EXCLUDED.orders_scanned, + gross_total = EXCLUDED.gross_total, + discount_total = EXCLUDED.discount_total, + tax_total = EXCLUDED.tax_total, + net_total = EXCLUDED.net_total, + avg_order_total = EXCLUDED.avg_order_total, + top_sku = EXCLUDED.top_sku, + top_sku_qty = EXCLUDED.top_sku_qty, + note = COALESCE(EXCLUDED.note, app_public.order_rollup.note), + updated_at = now(); + get diagnostics v_rowcount = row_count; + v_orders_upserted := v_rowcount; + v_sql := format( + 'SELECT count(*)::int FROM %I.%I WHERE org_id = $1 AND created_at >= $2 AND created_at < $3', + 'app_public', + 'app_order' + ); + execute v_sql into v_rowcount using p_org_id, p_from_ts, p_to_ts; + if p_debug then + raise notice 'dynamic count(app_order)=%', v_rowcount; + end if; + org_id := p_org_id; + user_id := p_user_id; + period_from := p_from_ts; + period_to := p_to_ts; + orders_scanned := v_orders_scanned; + orders_upserted := v_orders_upserted; + gross_total := v_gross; + discount_total := v_discount; + tax_total := v_tax; + net_total := v_net; + avg_order_total := round(v_avg, p_round_to); + top_sku := v_top_sku; + top_sku_qty := v_top_sku_qty; + message := format( + 'rollup ok: gross=%s discount=%s tax=%s net=%s (discount_rate=%s tax_rate=%s)', + v_gross, v_discount, v_tax, v_net, v_discount_rate, v_tax_rate + ); + return next; + return; +exception + when unique_violation then + raise notice 'unique_violation: %', SQLERRM; + raise; + when others then + if p_debug then + raise notice 'error: % (%:%)', SQLERRM, SQLSTATE, SQLERRM; + end if; + raise; end" `; @@ -247,159 +245,157 @@ exports[`uppercase: big-function.sql 1`] = ` v_rowcount int := 0; v_lock_key bigint := ('x' || substr(md5(p_org_id::text), 1, 16))::bit(64)::bigint; BEGIN - BEGIN - IF p_org_id IS NULL OR p_user_id IS NULL THEN - RAISE EXCEPTION 'p_org_id and p_user_id are required'; - END IF; - IF p_from_ts > p_to_ts THEN - RAISE EXCEPTION 'p_from_ts (%) must be <= p_to_ts (%)', p_from_ts, p_to_ts; - END IF; - IF p_max_rows < 1 OR p_max_rows > 10000 THEN - RAISE EXCEPTION 'p_max_rows out of range: %', p_max_rows; - END IF; - IF p_round_to < 0 OR p_round_to > 6 THEN - RAISE EXCEPTION 'p_round_to out of range: %', p_round_to; - END IF; - IF p_lock THEN - 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; - END IF; - WITH base AS ( - SELECT - o.id, - o.total_amount::numeric AS total_amount, - o.currency, - o.created_at - FROM app_public.app_order o - WHERE o.org_id = p_org_id - AND o.user_id = p_user_id - AND o.created_at >= p_from_ts - AND o.created_at < p_to_ts - AND o.total_amount::numeric >= v_min_total - AND o.currency = p_currency - ORDER BY o.created_at DESC - LIMIT p_max_rows - ), - totals AS ( - SELECT - count(*)::int AS orders_scanned, - COALESCE(sum(total_amount), 0) AS gross_total, - COALESCE(avg(total_amount), 0) AS avg_total - FROM base - ) - SELECT - t.orders_scanned, - t.gross_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); - ELSE - v_discount := 0; - END IF; - v_tax := round(GREATEST(v_gross - v_discount, 0) * v_tax_rate, p_round_to); - v_net := round((v_gross - v_discount + v_tax) * power(10::numeric, 0), p_round_to); + IF p_org_id IS NULL OR p_user_id IS NULL THEN + RAISE EXCEPTION 'p_org_id and p_user_id are required'; + END IF; + IF p_from_ts > p_to_ts THEN + RAISE EXCEPTION 'p_from_ts (%) must be <= p_to_ts (%)', p_from_ts, p_to_ts; + END IF; + IF p_max_rows < 1 OR p_max_rows > 10000 THEN + RAISE EXCEPTION 'p_max_rows out of range: %', p_max_rows; + END IF; + IF p_round_to < 0 OR p_round_to > 6 THEN + RAISE EXCEPTION 'p_round_to out of range: %', p_round_to; + END IF; + IF p_lock THEN + 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; + END IF; + WITH base AS ( SELECT - oi.sku, - 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 - AND o.user_id = p_user_id - AND o.created_at >= p_from_ts - AND o.created_at < p_to_ts - AND o.currency = p_currency - GROUP BY oi.sku - ORDER BY qty DESC, oi.sku ASC - LIMIT 1; - INSERT INTO app_public.order_rollup ( - org_id, - user_id, - period_from, - period_to, - currency, - orders_scanned, - gross_total, - discount_total, - tax_total, - net_total, - avg_order_total, - top_sku, - top_sku_qty, - note, - updated_at - ) - VALUES ( - p_org_id, - p_user_id, - p_from_ts, - p_to_ts, - p_currency, - v_orders_scanned, - v_gross, - v_discount, - v_tax, - v_net, - v_avg, - v_top_sku, - v_top_sku_qty, - p_note, - now() - ) - ON CONFLICT (org_id, user_id, period_from, period_to, currency) - DO UPDATE SET - orders_scanned = EXCLUDED.orders_scanned, - gross_total = EXCLUDED.gross_total, - discount_total = EXCLUDED.discount_total, - tax_total = EXCLUDED.tax_total, - net_total = EXCLUDED.net_total, - avg_order_total = EXCLUDED.avg_order_total, - top_sku = EXCLUDED.top_sku, - top_sku_qty = EXCLUDED.top_sku_qty, - note = COALESCE(EXCLUDED.note, app_public.order_rollup.note), - updated_at = now(); - GET DIAGNOSTICS v_rowcount = ROW_COUNT; - v_orders_upserted := v_rowcount; - v_sql := format( - 'SELECT count(*)::int FROM %I.%I WHERE org_id = $1 AND created_at >= $2 AND created_at < $3', - 'app_public', - 'app_order' - ); - EXECUTE v_sql INTO v_rowcount USING p_org_id, p_from_ts, p_to_ts; - IF p_debug THEN - RAISE NOTICE 'dynamic count(app_order)=%', v_rowcount; - END IF; - org_id := p_org_id; - user_id := p_user_id; - period_from := p_from_ts; - period_to := p_to_ts; - orders_scanned := v_orders_scanned; - orders_upserted := v_orders_upserted; - gross_total := v_gross; - discount_total := v_discount; - tax_total := v_tax; - net_total := v_net; - avg_order_total := round(v_avg, p_round_to); - top_sku := v_top_sku; - top_sku_qty := v_top_sku_qty; - message := format( - 'rollup ok: gross=%s discount=%s tax=%s net=%s (discount_rate=%s tax_rate=%s)', - v_gross, v_discount, v_tax, v_net, v_discount_rate, v_tax_rate - ); - RETURN NEXT; - RETURN; - EXCEPTION - WHEN unique_violation THEN - RAISE NOTICE 'unique_violation: %', SQLERRM; - RAISE; - WHEN others THEN - IF p_debug THEN - RAISE NOTICE 'error: % (%:%)', SQLERRM, SQLSTATE, SQLERRM; - END IF; - RAISE; - END; + o.id, + o.total_amount::numeric AS total_amount, + o.currency, + o.created_at + FROM app_public.app_order o + WHERE o.org_id = p_org_id + AND o.user_id = p_user_id + AND o.created_at >= p_from_ts + AND o.created_at < p_to_ts + AND o.total_amount::numeric >= v_min_total + AND o.currency = p_currency + ORDER BY o.created_at DESC + LIMIT p_max_rows + ), + totals AS ( + SELECT + count(*)::int AS orders_scanned, + COALESCE(sum(total_amount), 0) AS gross_total, + COALESCE(avg(total_amount), 0) AS avg_total + FROM base + ) + SELECT + t.orders_scanned, + t.gross_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); + ELSE + v_discount := 0; + END IF; + v_tax := round(GREATEST(v_gross - v_discount, 0) * v_tax_rate, p_round_to); + 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 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 + AND o.user_id = p_user_id + AND o.created_at >= p_from_ts + AND o.created_at < p_to_ts + AND o.currency = p_currency + GROUP BY oi.sku + ORDER BY qty DESC, oi.sku ASC + LIMIT 1; + INSERT INTO app_public.order_rollup ( + org_id, + user_id, + period_from, + period_to, + currency, + orders_scanned, + gross_total, + discount_total, + tax_total, + net_total, + avg_order_total, + top_sku, + top_sku_qty, + note, + updated_at + ) + VALUES ( + p_org_id, + p_user_id, + p_from_ts, + p_to_ts, + p_currency, + v_orders_scanned, + v_gross, + v_discount, + v_tax, + v_net, + v_avg, + v_top_sku, + v_top_sku_qty, + p_note, + now() + ) + ON CONFLICT (org_id, user_id, period_from, period_to, currency) + DO UPDATE SET + orders_scanned = EXCLUDED.orders_scanned, + gross_total = EXCLUDED.gross_total, + discount_total = EXCLUDED.discount_total, + tax_total = EXCLUDED.tax_total, + net_total = EXCLUDED.net_total, + avg_order_total = EXCLUDED.avg_order_total, + top_sku = EXCLUDED.top_sku, + top_sku_qty = EXCLUDED.top_sku_qty, + note = COALESCE(EXCLUDED.note, app_public.order_rollup.note), + updated_at = now(); + GET DIAGNOSTICS v_rowcount = ROW_COUNT; + v_orders_upserted := v_rowcount; + v_sql := format( + 'SELECT count(*)::int FROM %I.%I WHERE org_id = $1 AND created_at >= $2 AND created_at < $3', + 'app_public', + 'app_order' + ); + EXECUTE v_sql INTO v_rowcount USING p_org_id, p_from_ts, p_to_ts; + IF p_debug THEN + RAISE NOTICE 'dynamic count(app_order)=%', v_rowcount; + END IF; + org_id := p_org_id; + user_id := p_user_id; + period_from := p_from_ts; + period_to := p_to_ts; + orders_scanned := v_orders_scanned; + orders_upserted := v_orders_upserted; + gross_total := v_gross; + discount_total := v_discount; + tax_total := v_tax; + net_total := v_net; + avg_order_total := round(v_avg, p_round_to); + top_sku := v_top_sku; + top_sku_qty := v_top_sku_qty; + message := format( + 'rollup ok: gross=%s discount=%s tax=%s net=%s (discount_rate=%s tax_rate=%s)', + v_gross, v_discount, v_tax, v_net, v_discount_rate, v_tax_rate + ); + RETURN NEXT; + RETURN; +EXCEPTION + WHEN unique_violation THEN + RAISE NOTICE 'unique_violation: %', SQLERRM; + RAISE; + WHEN others THEN + IF p_debug THEN + RAISE NOTICE 'error: % (%:%)', SQLERRM, SQLSTATE, SQLERRM; + END IF; + RAISE; END" `; diff --git a/packages/plpgsql-deparser/package.json b/packages/plpgsql-deparser/package.json index e4cdac58..9b9c2a91 100644 --- a/packages/plpgsql-deparser/package.json +++ b/packages/plpgsql-deparser/package.json @@ -42,7 +42,7 @@ "database" ], "devDependencies": { - "@libpg-query/parser": "^17.6.3", + "@libpg-query/parser": "^17.8.0", "libpg-query": "17.7.3", "makage": "^0.1.8" }, diff --git a/packages/plpgsql-deparser/src/plpgsql-deparser.ts b/packages/plpgsql-deparser/src/plpgsql-deparser.ts index 843361fc..2f099d36 100644 --- a/packages/plpgsql-deparser/src/plpgsql-deparser.ts +++ b/packages/plpgsql-deparser/src/plpgsql-deparser.ts @@ -86,6 +86,8 @@ export interface PLpgSQLDeparserContext { loopVarLinenos?: Set; /** Map of block lineno to the set of datum indices that belong to that block */ blockDatumMap?: Map>; + /** Datum index of the function's OUT parameter(s), when present */ + outParamVarno?: number; } /** @@ -158,14 +160,20 @@ export class PLpgSQLDeparser { * @param returnInfo - Optional return type info for correct RETURN statement handling */ deparseFunction(func: PLpgSQL_function, returnInfo?: ReturnInfo): string { + // Normalize the action: strip the compiler-generated implicit final + // RETURN and unwrap the compiler-generated wrapper block + const action = func.action + ? this.unwrapCompilerWrapperBlock(this.stripImplicitFinalReturn(func.action, func.out_param_varno)) + : undefined; + // Collect loop-introduced variables before generating DECLARE section const loopVarLinenos = new Set(); - if (func.action) { - this.collectLoopVariables(func.action, loopVarLinenos); + if (action) { + this.collectLoopVariables(action, loopVarLinenos); } // Build the block-to-datum mapping for nested DECLARE sections - const blockDatumMap = this.buildBlockDatumMap(func.datums, func.action, loopVarLinenos); + const blockDatumMap = this.buildBlockDatumMap(func.datums, action, loopVarLinenos); // Collect all datum indices that belong to nested blocks (to exclude from top-level) const nestedDatumIndices = new Set(); @@ -182,6 +190,7 @@ export class PLpgSQLDeparser { returnInfo, loopVarLinenos, blockDatumMap, + outParamVarno: func.out_param_varno, }; const parts: string[] = []; @@ -189,8 +198,8 @@ export class PLpgSQLDeparser { // Extract label from action block - it should come before DECLARE // In PL/pgSQL, the syntax is: <