diff --git a/__fixtures__/plpgsql-generated/generated.json b/__fixtures__/plpgsql-generated/generated.json index f14cbc70..13f6bf31 100644 --- a/__fixtures__/plpgsql-generated/generated.json +++ b/__fixtures__/plpgsql-generated/generated.json @@ -68,6 +68,18 @@ "plpgsql_transaction-35.sql": "CREATE PROCEDURE transaction_test10b(INOUT x int)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n x := x - 1;\n ROLLBACK;\nEND;\n$$", "plpgsql_transaction-36.sql": "-- transaction timestamp vs. statement timestamp\nCREATE PROCEDURE transaction_test11()\nLANGUAGE plpgsql\nAS $$\nDECLARE\n s1 timestamp with time zone;\n s2 timestamp with time zone;\n s3 timestamp with time zone;\n t1 timestamp with time zone;\n t2 timestamp with time zone;\n t3 timestamp with time zone;\nBEGIN\n s1 := statement_timestamp();\n t1 := transaction_timestamp();\n ASSERT s1 = t1;\n PERFORM pg_sleep(0.001);\n COMMIT;\n s2 := statement_timestamp();\n t2 := transaction_timestamp();\n ASSERT s2 = s1;\n ASSERT t2 > t1;\n PERFORM pg_sleep(0.001);\n ROLLBACK;\n s3 := statement_timestamp();\n t3 := transaction_timestamp();\n ASSERT s3 = s1;\n ASSERT t3 > t2;\nEND;\n$$", "plpgsql_transaction-37.sql": "DO LANGUAGE plpgsql $$\nBEGIN\n ROLLBACK;\n SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;\n FOR i IN 0..3 LOOP\n RAISE INFO 'transaction_isolation = %', current_setting('transaction_isolation');\n INSERT INTO test1 (a) VALUES (i);\n IF i % 2 = 0 THEN\n COMMIT AND CHAIN;\n ELSE\n ROLLBACK AND CHAIN;\n END IF;\n END LOOP;\nEND\n$$", + "plpgsql_schema_rename-1.sql": "-- Fixtures to test schema rename traversal\n-- These exercise complex scenarios with multiple schema references across different contexts\n\n-- Test 1: Function with schema-qualified table references in SELECT\nCREATE FUNCTION app_public.get_user_stats(p_user_id int)\nRETURNS int\nLANGUAGE plpgsql AS $$\nDECLARE\n total_count int;\nBEGIN\n SELECT count(*) INTO total_count\n FROM app_public.users u\n JOIN app_public.orders o ON o.user_id = u.id\n WHERE u.id = p_user_id;\n RETURN total_count;\nEND$$", + "plpgsql_schema_rename-2.sql": "-- Test 2: Trigger function with INSERT into schema-qualified table\nCREATE FUNCTION app_public.audit_changes()\nRETURNS trigger\nLANGUAGE plpgsql AS $$\nBEGIN\n INSERT INTO app_public.audit_log (table_name, operation, old_data, new_data, changed_at)\n VALUES (TG_TABLE_NAME, TG_OP, to_json(OLD), to_json(NEW), now());\n \n IF TG_OP = 'DELETE' THEN\n RETURN OLD;\n END IF;\n RETURN NEW;\nEND$$", + "plpgsql_schema_rename-3.sql": "-- Test 3: Function with UPDATE to schema-qualified table\nCREATE FUNCTION app_public.update_user_status(p_user_id int, p_status text)\nRETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n UPDATE app_public.users\n SET status = p_status, updated_at = now()\n WHERE id = p_user_id;\n \n INSERT INTO app_public.status_history (user_id, status, changed_at)\n VALUES (p_user_id, p_status, now());\nEND$$", + "plpgsql_schema_rename-4.sql": "-- Test 4: Function with DELETE from schema-qualified table\nCREATE FUNCTION app_public.cleanup_old_sessions(p_days int)\nRETURNS int\nLANGUAGE plpgsql AS $$\nDECLARE\n deleted_count int;\nBEGIN\n DELETE FROM app_public.sessions\n WHERE created_at < now() - (p_days || ' days')::interval;\n \n GET DIAGNOSTICS deleted_count = ROW_COUNT;\n RETURN deleted_count;\nEND$$", + "plpgsql_schema_rename-5.sql": "-- Test 5: SETOF function with RETURN QUERY and schema-qualified tables\nCREATE FUNCTION app_public.get_active_orders(p_status text)\nRETURNS SETOF int\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN QUERY\n SELECT o.id\n FROM app_public.orders o\n JOIN app_public.users u ON u.id = o.user_id\n WHERE o.status = p_status\n AND u.is_active = true;\n RETURN;\nEND$$", + "plpgsql_schema_rename-6.sql": "-- Test 6: Function with schema-qualified function calls in expressions\nCREATE FUNCTION app_public.calculate_order_total(p_order_id int)\nRETURNS numeric\nLANGUAGE plpgsql AS $$\nDECLARE\n subtotal numeric;\n tax_amount numeric;\n discount numeric;\nBEGIN\n SELECT sum(quantity * price) INTO subtotal\n FROM app_public.order_items\n WHERE order_id = p_order_id;\n \n tax_amount := app_public.get_tax_rate() * subtotal;\n discount := app_public.get_discount(p_order_id);\n \n RETURN subtotal + tax_amount - discount;\nEND$$", + "plpgsql_schema_rename-7.sql": "-- Test 7: Function with multiple schema references in complex query\nCREATE FUNCTION app_public.get_user_dashboard(p_user_id int)\nRETURNS TABLE(metric_name text, metric_value numeric)\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN QUERY\n SELECT 'total_orders'::text, count(*)::numeric\n FROM app_public.orders\n WHERE user_id = p_user_id\n UNION ALL\n SELECT 'total_spent'::text, coalesce(sum(total), 0)::numeric\n FROM app_public.orders\n WHERE user_id = p_user_id\n UNION ALL\n SELECT 'active_subscriptions'::text, count(*)::numeric\n FROM app_public.subscriptions\n WHERE user_id = p_user_id AND status = 'active';\n RETURN;\nEND$$", + "plpgsql_schema_rename-8.sql": "-- Test 8: Trigger function with conditional logic and multiple tables\nCREATE FUNCTION app_public.sync_user_profile()\nRETURNS trigger\nLANGUAGE plpgsql AS $$\nDECLARE\n profile_exists boolean;\nBEGIN\n SELECT EXISTS(\n SELECT 1 FROM app_public.profiles WHERE user_id = NEW.id\n ) INTO profile_exists;\n \n IF NOT profile_exists THEN\n INSERT INTO app_public.profiles (user_id, created_at)\n VALUES (NEW.id, now());\n ELSE\n UPDATE app_public.profiles\n SET updated_at = now()\n WHERE user_id = NEW.id;\n END IF;\n \n PERFORM app_public.notify_profile_change(NEW.id);\n RETURN NEW;\nEND$$", + "plpgsql_schema_rename-9.sql": "-- Test 9: Function with CTE and schema-qualified references\nCREATE FUNCTION app_public.get_top_customers(p_limit int)\nRETURNS SETOF int\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN QUERY\n WITH customer_totals AS (\n SELECT user_id, sum(total) as total_spent\n FROM app_public.orders\n WHERE status = 'completed'\n GROUP BY user_id\n )\n SELECT ct.user_id\n FROM customer_totals ct\n JOIN app_public.users u ON u.id = ct.user_id\n WHERE u.is_active = true\n ORDER BY ct.total_spent DESC\n LIMIT p_limit;\n RETURN;\nEND$$", + "plpgsql_schema_rename-10.sql": "-- Test 10: Function with subquery in WHERE clause\nCREATE FUNCTION app_public.get_users_with_orders()\nRETURNS SETOF int\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN QUERY\n SELECT u.id\n FROM app_public.users u\n WHERE EXISTS (\n SELECT 1 FROM app_public.orders o\n WHERE o.user_id = u.id\n );\n RETURN;\nEND$$", + "plpgsql_schema_rename-11.sql": "-- Test 11: Function referencing multiple schemas\nCREATE FUNCTION app_public.cross_schema_report(p_date date)\nRETURNS TABLE(source text, count bigint)\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN QUERY\n SELECT 'public_users'::text, count(*)\n FROM app_public.users\n WHERE created_at::date = p_date\n UNION ALL\n SELECT 'private_logs'::text, count(*)\n FROM app_private.activity_logs\n WHERE logged_at::date = p_date\n UNION ALL\n SELECT 'internal_metrics'::text, count(*)\n FROM app_internal.metrics\n WHERE recorded_at::date = p_date;\n RETURN;\nEND$$", + "plpgsql_schema_rename-12.sql": "-- Test 12: Procedure with schema-qualified references\nCREATE PROCEDURE app_public.process_batch(p_batch_id int)\nLANGUAGE plpgsql AS $$\nDECLARE\n item record;\nBEGIN\n FOR item IN\n SELECT * FROM app_public.batch_items\n WHERE batch_id = p_batch_id\n LOOP\n INSERT INTO app_public.processed_items (item_id, processed_at)\n VALUES (item.id, now());\n \n UPDATE app_public.batch_items\n SET status = 'processed'\n WHERE id = item.id;\n END LOOP;\n \n UPDATE app_public.batches\n SET status = 'completed', completed_at = now()\n WHERE id = p_batch_id;\nEND$$", "plpgsql_domain-1.sql": "CREATE FUNCTION test_argresult_booltrue(x booltrue, y bool) RETURNS booltrue AS $$\nbegin\nreturn y;\nend\n$$ LANGUAGE plpgsql", "plpgsql_domain-2.sql": "CREATE FUNCTION test_assign_booltrue(x bool, y bool) RETURNS booltrue AS $$\ndeclare v booltrue := x;\nbegin\nv := y;\nreturn v;\nend\n$$ LANGUAGE plpgsql", "plpgsql_domain-3.sql": "CREATE FUNCTION test_argresult_uint2(x uint2, y int) RETURNS uint2 AS $$\nbegin\nreturn y;\nend\n$$ LANGUAGE plpgsql", @@ -100,10 +112,31 @@ "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_deparser_fixes-15.sql": "-- Test 15: OUT parameters with SELECT INTO multiple variables\n-- This pattern is used in auth functions (sign_in, sign_up) where we need to\n-- populate multiple OUT parameters from a single SELECT statement\nCREATE FUNCTION test_out_params_select_into(\n p_user_id uuid,\n OUT id uuid,\n OUT user_id uuid,\n OUT access_token text,\n OUT access_token_expires_at timestamptz,\n OUT is_verified boolean,\n OUT totp_enabled boolean\n)\nLANGUAGE plpgsql AS $$\nDECLARE\n v_token_id uuid;\n v_plaintext_token text;\nBEGIN\n v_plaintext_token := encode(gen_random_bytes(48), 'hex');\n v_token_id := uuid_generate_v5(uuid_ns_url(), v_plaintext_token);\n \n INSERT INTO tokens (id, user_id, access_token_hash)\n VALUES (v_token_id, p_user_id, digest(v_plaintext_token, 'sha256'));\n \n SELECT tkn.id, tkn.user_id, v_plaintext_token, tkn.access_token_expires_at, tkn.is_verified, tkn.totp_enabled\n INTO id, user_id, access_token, access_token_expires_at, is_verified, totp_enabled\n FROM tokens AS tkn\n WHERE tkn.id = v_token_id;\n \n RETURN;\nEND$$", - "plpgsql_deparser_fixes-16.sql": "-- Test 16: OUT parameters with SELECT INTO and STRICT\nCREATE FUNCTION test_out_params_strict(\n p_id uuid,\n OUT name text,\n OUT email text\n)\nLANGUAGE plpgsql AS $$\nBEGIN\n SELECT u.name, u.email INTO STRICT name, email\n FROM users u\n WHERE u.id = p_id;\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_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_deparser_fixes-15.sql": "-- Test 15: OUT parameters with SELECT INTO multiple variables\n-- This pattern is used in auth functions (sign_in, sign_up) where we need to\n-- populate multiple OUT parameters from a single SELECT statement\nCREATE FUNCTION test_out_params_select_into(\n p_user_id uuid,\n OUT id uuid,\n OUT user_id uuid,\n OUT access_token text,\n OUT access_token_expires_at timestamptz,\n OUT is_verified boolean,\n OUT totp_enabled boolean\n)\nLANGUAGE plpgsql AS $$\nDECLARE\n v_token_id uuid;\n v_plaintext_token text;\nBEGIN\n v_plaintext_token := encode(gen_random_bytes(48), 'hex');\n v_token_id := uuid_generate_v5(uuid_ns_url(), v_plaintext_token);\n \n INSERT INTO tokens (id, user_id, access_token_hash)\n VALUES (v_token_id, p_user_id, digest(v_plaintext_token, 'sha256'));\n \n SELECT tkn.id, tkn.user_id, v_plaintext_token, tkn.access_token_expires_at, tkn.is_verified, tkn.totp_enabled\n INTO id, user_id, access_token, access_token_expires_at, is_verified, totp_enabled\n FROM tokens AS tkn\n WHERE tkn.id = v_token_id;\n \n RETURN;\nEND$$", + "plpgsql_deparser_fixes-16.sql": "-- Test 16: OUT parameters with SELECT INTO and STRICT\nCREATE FUNCTION test_out_params_strict(\n p_id uuid,\n OUT name text,\n OUT email text\n)\nLANGUAGE plpgsql AS $$\nBEGIN\n SELECT u.name, u.email INTO STRICT name, email\n FROM users u\n WHERE u.id = p_id;\nEND$$", + "plpgsql_deparser_fixes-17.sql": "-- =============================================================================\n-- Edge Case Tests: Nested Block Compositions (END; bug class)\n-- These test the exact bug class where END; of a nested block could be\n-- confused with statement keywords that follow it.\n-- =============================================================================\n\n-- Test 17: Nested block followed by RETURN (the original END; bug pattern)\nCREATE FUNCTION test_nested_block_return() RETURNS integer\nLANGUAGE plpgsql AS $$\nDECLARE\n v_result integer;\nBEGIN\n BEGIN\n v_result := 1;\n END;\n RETURN v_result;\nEND$$", + "plpgsql_deparser_fixes-18.sql": "-- Test 18: Nested block followed by IF\nCREATE FUNCTION test_nested_block_if() RETURNS boolean\nLANGUAGE plpgsql AS $$\nBEGIN\n BEGIN\n PERFORM setup_something();\n END;\n IF FOUND THEN\n RETURN TRUE;\n END IF;\n RETURN FALSE;\nEND$$", + "plpgsql_deparser_fixes-19.sql": "-- Test 19: Nested block followed by RAISE\nCREATE FUNCTION test_nested_block_raise() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n BEGIN\n PERFORM risky_operation();\n END;\n RAISE NOTICE 'Operation completed';\n RETURN;\nEND$$", + "plpgsql_deparser_fixes-20.sql": "-- Test 20: Nested block followed by PERFORM\nCREATE FUNCTION test_nested_block_perform() RETURNS integer\nLANGUAGE plpgsql AS $$\nDECLARE\n v_count integer;\nBEGIN\n BEGIN\n v_count := 42;\n END;\n PERFORM log_result(v_count);\n RETURN v_count;\nEND$$", + "plpgsql_deparser_fixes-21.sql": "-- Test 21: Nested block followed by assignment\nCREATE FUNCTION test_nested_block_assign() RETURNS text\nLANGUAGE plpgsql AS $$\nDECLARE\n v_status text;\nBEGIN\n BEGIN\n PERFORM init();\n END;\n v_status := 'complete';\n RETURN v_status;\nEND$$", + "plpgsql_deparser_fixes-22.sql": "-- Test 22: Labeled nested block\nCREATE FUNCTION test_labeled_nested_block() RETURNS boolean\nLANGUAGE plpgsql AS $$\nBEGIN\n <>\n BEGIN\n PERFORM do_work();\n END inner;\n RETURN TRUE;\nEND$$", + "plpgsql_deparser_fixes-23.sql": "-- =============================================================================\n-- Edge Case Tests: Blocks Inside Control Structures\n-- =============================================================================\n\n-- Test 23: Block inside IF THEN branch with exception handler\nCREATE FUNCTION test_block_in_if() RETURNS integer\nLANGUAGE plpgsql AS $$\nBEGIN\n IF 1 > 0 THEN\n BEGIN\n PERFORM positive_handler();\n EXCEPTION\n WHEN others THEN\n RAISE NOTICE 'error in positive handler';\n END;\n ELSE\n RETURN 0;\n END IF;\n RETURN 1;\nEND$$", + "plpgsql_deparser_fixes-24.sql": "-- Test 24: Block inside LOOP body with exception handler\nCREATE FUNCTION test_block_in_loop() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n LOOP\n BEGIN\n PERFORM process_next();\n EXCEPTION\n WHEN others THEN\n RAISE NOTICE 'skipping bad record';\n END;\n EXIT WHEN NOT FOUND;\n END LOOP;\n RETURN;\nEND$$", + "plpgsql_deparser_fixes-25.sql": "-- Test 25: Block inside CASE WHEN\nCREATE FUNCTION test_block_in_case(p_status text) RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n CASE p_status\n WHEN 'retry' THEN\n BEGIN\n PERFORM retry_operation();\n EXCEPTION\n WHEN others THEN\n RAISE EXCEPTION 'retry failed';\n END;\n WHEN 'skip' THEN\n RAISE NOTICE 'skipping';\n END CASE;\n RETURN;\nEND$$", + "plpgsql_deparser_fixes-26.sql": "-- =============================================================================\n-- Edge Case Tests: Deep Nesting & Sequential Blocks\n-- =============================================================================\n\n-- Test 26: Two sequential nested blocks\nCREATE FUNCTION test_sequential_blocks() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n BEGIN\n PERFORM step_one();\n END;\n BEGIN\n PERFORM step_two();\n END;\n RETURN;\nEND$$", + "plpgsql_deparser_fixes-27.sql": "-- Test 27: Triple-nested blocks\nCREATE FUNCTION test_triple_nested() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n BEGIN\n BEGIN\n PERFORM deep_call();\n END;\n RAISE NOTICE 'middle';\n END;\n RETURN;\nEND$$", + "plpgsql_deparser_fixes-28.sql": "-- Test 28: Block inside exception handler action\nCREATE FUNCTION test_block_in_exception() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n PERFORM risky();\nEXCEPTION\n WHEN others THEN\n BEGIN\n PERFORM log_error();\n EXCEPTION\n WHEN others THEN\n RAISE NOTICE 'even logging failed';\n END;\nEND$$", + "plpgsql_deparser_fixes-29.sql": "-- =============================================================================\n-- Edge Case Tests: Untested Statement Types\n-- =============================================================================\n\n-- Test 29: FOR integer loop\nCREATE FUNCTION test_for_integer_loop() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n FOR i IN 1..10 LOOP\n PERFORM process(i);\n END LOOP;\n RETURN;\nEND$$", + "plpgsql_deparser_fixes-30.sql": "-- Test 30: FOR query loop\nCREATE FUNCTION test_for_query_loop() RETURNS void\nLANGUAGE plpgsql AS $$\nDECLARE\n rec record;\nBEGIN\n FOR rec IN SELECT id, name FROM my_table LOOP\n PERFORM handle(rec);\n END LOOP;\n RETURN;\nEND$$", + "plpgsql_deparser_fixes-31.sql": "-- Test 31: Labeled FOR loop with EXIT\nCREATE FUNCTION test_labeled_for_loop() RETURNS void\nLANGUAGE plpgsql AS $$\nDECLARE\n rec record;\nBEGIN\n <>\n FOR rec IN SELECT id, name FROM items LOOP\n EXIT row_loop WHEN rec.id IS NULL;\n PERFORM process(rec);\n END LOOP row_loop;\n RETURN;\nEND$$", + "plpgsql_deparser_fixes-32.sql": "-- Test 32: RETURN NEXT in set-returning function with OUT parameters\nCREATE FUNCTION test_return_next_out(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-33.sql": "-- Test 33: RETURN QUERY\nCREATE FUNCTION test_return_query_simple() RETURNS SETOF record\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN QUERY SELECT id, name FROM my_table WHERE active = TRUE;\nEND$$", + "plpgsql_deparser_fixes-34.sql": "-- Test 34: ASSERT statement\nCREATE FUNCTION test_assert(p_x integer) RETURNS integer\nLANGUAGE plpgsql AS $$\nBEGIN\n ASSERT p_x > 0, 'x must be positive';\n RETURN p_x;\nEND$$", + "plpgsql_deparser_fixes-35.sql": "-- Test 35: CALL statement\nCREATE FUNCTION test_call_statement() RETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n CALL my_procedure(1, 'hello');\n RETURN;\nEND$$", + "plpgsql_deparser_fixes-36.sql": "-- =============================================================================\n-- Edge Case Tests: Real-World Patterns\n-- =============================================================================\n\n-- Test 36: Permission bitnum trigger pattern (the function that exposed the END; bug)\nCREATE FUNCTION test_permission_bitnum_trigger() RETURNS trigger\nLANGUAGE plpgsql AS $$\nDECLARE\n bitlen int;\n v_len int;\nBEGIN\n v_len := 32;\n BEGIN\n bitlen := bit_length(NEW.bitstr);\n EXCEPTION\n WHEN others THEN\n bitlen := 0;\n END;\n IF bitlen = 0 THEN\n NEW.bitstr := lpad('', v_len, '0');\n END IF;\n RETURN NEW;\nEND$$", + "plpgsql_deparser_fixes-37.sql": "-- Test 37: Multi-step sign-in pattern (deeply nested IF chains)\nCREATE FUNCTION test_signin_pattern(v_email text) RETURNS record\nLANGUAGE plpgsql AS $$\nDECLARE\n v_user record;\n v_secret record;\nBEGIN\n SELECT * INTO v_user FROM users WHERE email = v_email;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'USER_NOT_FOUND';\n END IF;\n SELECT * INTO v_secret FROM secrets WHERE user_id = v_user.id;\n IF NOT FOUND THEN\n RAISE EXCEPTION 'NO_CREDENTIALS';\n END IF;\n IF v_secret.locked_at IS NOT NULL THEN\n RAISE EXCEPTION 'ACCOUNT_LOCKED';\n END IF;\n RETURN v_user;\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$$", "plpgsql_control-4.sql": "do $$\nbegin\n for i in reverse 1..3 by -1 loop\n raise notice 'reverse 1..3 by -1: i = %', i;\n end loop;\nend$$", @@ -191,4 +224,4 @@ "plpgsql_array-21.sql": "-- some types don't support arrays\ndo $$\ndeclare\n v pg_node_tree;\n v1 v%type[];\nbegin\nend;\n$$", "plpgsql_array-22.sql": "-- check functionality\ndo $$\ndeclare\n v1 int;\n v2 varchar;\n a1 v1%type[];\n a2 v2%type[];\nbegin\n v1 := 10;\n v2 := 'Hi';\n a1 := array[v1,v1];\n a2 := array[v2,v2];\n raise notice '% %', a1, a2;\nend;\n$$", "plpgsql_array-23.sql": "do $$\ndeclare tg array_test_table%rowtype[];\nbegin\n tg := array(select array_test_table from array_test_table);\n raise notice '%', tg;\n tg := array(select row(a,b) from array_test_table);\n raise notice '%', tg;\nend;\n$$" -} +} \ No newline at end of file diff --git a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql index 3c80cc59..84bf64df 100644 --- a/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql +++ b/__fixtures__/plpgsql/plpgsql_deparser_fixes.sql @@ -206,3 +206,302 @@ BEGIN FROM users u WHERE u.id = p_id; END$$; + +-- ============================================================================= +-- Edge Case Tests: Nested Block Compositions (END; bug class) +-- These test the exact bug class where END; of a nested block could be +-- confused with statement keywords that follow it. +-- ============================================================================= + +-- Test 17: Nested block followed by RETURN (the original END; bug pattern) +CREATE FUNCTION test_nested_block_return() RETURNS integer +LANGUAGE plpgsql AS $$ +DECLARE + v_result integer; +BEGIN + BEGIN + v_result := 1; + END; + RETURN v_result; +END$$; + +-- Test 18: Nested block followed by IF +CREATE FUNCTION test_nested_block_if() RETURNS boolean +LANGUAGE plpgsql AS $$ +BEGIN + BEGIN + PERFORM setup_something(); + END; + IF FOUND THEN + RETURN TRUE; + END IF; + RETURN FALSE; +END$$; + +-- Test 19: Nested block followed by RAISE +CREATE FUNCTION test_nested_block_raise() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + BEGIN + PERFORM risky_operation(); + END; + RAISE NOTICE 'Operation completed'; + RETURN; +END$$; + +-- Test 20: Nested block followed by PERFORM +CREATE FUNCTION test_nested_block_perform() RETURNS integer +LANGUAGE plpgsql AS $$ +DECLARE + v_count integer; +BEGIN + BEGIN + v_count := 42; + END; + PERFORM log_result(v_count); + RETURN v_count; +END$$; + +-- Test 21: Nested block followed by assignment +CREATE FUNCTION test_nested_block_assign() RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + v_status text; +BEGIN + BEGIN + PERFORM init(); + END; + v_status := 'complete'; + RETURN v_status; +END$$; + +-- Test 22: Labeled nested block +CREATE FUNCTION test_labeled_nested_block() RETURNS boolean +LANGUAGE plpgsql AS $$ +BEGIN + <> + BEGIN + PERFORM do_work(); + END inner; + RETURN TRUE; +END$$; + +-- ============================================================================= +-- Edge Case Tests: Blocks Inside Control Structures +-- ============================================================================= + +-- Test 23: Block inside IF THEN branch with exception handler +CREATE FUNCTION test_block_in_if() RETURNS integer +LANGUAGE plpgsql AS $$ +BEGIN + IF 1 > 0 THEN + BEGIN + PERFORM positive_handler(); + EXCEPTION + WHEN others THEN + RAISE NOTICE 'error in positive handler'; + END; + ELSE + RETURN 0; + END IF; + RETURN 1; +END$$; + +-- Test 24: Block inside LOOP body with exception handler +CREATE FUNCTION test_block_in_loop() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + LOOP + BEGIN + PERFORM process_next(); + EXCEPTION + WHEN others THEN + RAISE NOTICE 'skipping bad record'; + END; + EXIT WHEN NOT FOUND; + END LOOP; + RETURN; +END$$; + +-- Test 25: Block inside CASE WHEN +CREATE FUNCTION test_block_in_case(p_status text) RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + CASE p_status + WHEN 'retry' THEN + BEGIN + PERFORM retry_operation(); + EXCEPTION + WHEN others THEN + RAISE EXCEPTION 'retry failed'; + END; + WHEN 'skip' THEN + RAISE NOTICE 'skipping'; + END CASE; + RETURN; +END$$; + +-- ============================================================================= +-- Edge Case Tests: Deep Nesting & Sequential Blocks +-- ============================================================================= + +-- Test 26: Two sequential nested blocks +CREATE FUNCTION test_sequential_blocks() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + BEGIN + PERFORM step_one(); + END; + BEGIN + PERFORM step_two(); + END; + RETURN; +END$$; + +-- Test 27: Triple-nested blocks +CREATE FUNCTION test_triple_nested() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + BEGIN + BEGIN + PERFORM deep_call(); + END; + RAISE NOTICE 'middle'; + END; + RETURN; +END$$; + +-- Test 28: Block inside exception handler action +CREATE FUNCTION test_block_in_exception() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + PERFORM risky(); +EXCEPTION + WHEN others THEN + BEGIN + PERFORM log_error(); + EXCEPTION + WHEN others THEN + RAISE NOTICE 'even logging failed'; + END; +END$$; + +-- ============================================================================= +-- Edge Case Tests: Untested Statement Types +-- ============================================================================= + +-- Test 29: FOR integer loop +CREATE FUNCTION test_for_integer_loop() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + FOR i IN 1..10 LOOP + PERFORM process(i); + END LOOP; + RETURN; +END$$; + +-- Test 30: FOR query loop +CREATE FUNCTION test_for_query_loop() RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE + rec record; +BEGIN + FOR rec IN SELECT id, name FROM my_table LOOP + PERFORM handle(rec); + END LOOP; + RETURN; +END$$; + +-- Test 31: Labeled FOR loop with EXIT +CREATE FUNCTION test_labeled_for_loop() RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE + rec record; +BEGIN + <> + FOR rec IN SELECT id, name FROM items LOOP + EXIT row_loop WHEN rec.id IS NULL; + PERFORM process(rec); + END LOOP row_loop; + RETURN; +END$$; + +-- Test 32: RETURN NEXT in set-returning function with OUT parameters +CREATE FUNCTION test_return_next_out(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 33: RETURN QUERY +CREATE FUNCTION test_return_query_simple() RETURNS SETOF record +LANGUAGE plpgsql AS $$ +BEGIN + RETURN QUERY SELECT id, name FROM my_table WHERE active = TRUE; +END$$; + +-- Test 34: ASSERT statement +CREATE FUNCTION test_assert(p_x integer) RETURNS integer +LANGUAGE plpgsql AS $$ +BEGIN + ASSERT p_x > 0, 'x must be positive'; + RETURN p_x; +END$$; + +-- Test 35: CALL statement +CREATE FUNCTION test_call_statement() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + CALL my_procedure(1, 'hello'); + RETURN; +END$$; + +-- ============================================================================= +-- Edge Case Tests: Real-World Patterns +-- ============================================================================= + +-- Test 36: Permission bitnum trigger pattern (the function that exposed the END; bug) +CREATE FUNCTION test_permission_bitnum_trigger() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + bitlen int; + v_len int; +BEGIN + v_len := 32; + BEGIN + bitlen := bit_length(NEW.bitstr); + EXCEPTION + WHEN others THEN + bitlen := 0; + END; + IF bitlen = 0 THEN + NEW.bitstr := lpad('', v_len, '0'); + END IF; + RETURN NEW; +END$$; + +-- Test 37: Multi-step sign-in pattern (deeply nested IF chains) +CREATE FUNCTION test_signin_pattern(v_email text) RETURNS record +LANGUAGE plpgsql AS $$ +DECLARE + v_user record; + v_secret record; +BEGIN + SELECT * INTO v_user FROM users WHERE email = v_email; + IF NOT FOUND THEN + RAISE EXCEPTION 'USER_NOT_FOUND'; + END IF; + SELECT * INTO v_secret FROM secrets WHERE user_id = v_user.id; + IF NOT FOUND THEN + RAISE EXCEPTION 'NO_CREDENTIALS'; + END IF; + IF v_secret.locked_at IS NOT NULL THEN + RAISE EXCEPTION 'ACCOUNT_LOCKED'; + END IF; + RETURN v_user; +END$$; 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 dc0e93d4..ac18db54 100644 --- a/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/__snapshots__/deparser-fixes.test.ts.snap @@ -161,6 +161,54 @@ exports[`plpgsql-deparser bug fixes Record field qualification (recfield) should END" `; +exports[`plpgsql-deparser bug fixes blocks inside control structures should handle block inside CASE WHEN 1`] = ` +"BEGIN + CASE p_status + WHEN 'retry' THEN + BEGIN + PERFORM retry_operation(); + EXCEPTION + WHEN others THEN + RAISE EXCEPTION 'retry failed'; + END; + WHEN 'skip' THEN + RAISE NOTICE 'skipping'; + END CASE; + RETURN; +END" +`; + +exports[`plpgsql-deparser bug fixes blocks inside control structures should handle block inside IF THEN branch 1`] = ` +"BEGIN + IF 1 > 0 THEN + BEGIN + PERFORM positive_handler(); + EXCEPTION + WHEN others THEN + RAISE NOTICE 'error in positive handler'; + END; + ELSE + RETURN 0; + END IF; + RETURN 1; +END" +`; + +exports[`plpgsql-deparser bug fixes blocks inside control structures should handle block inside LOOP body 1`] = ` +"BEGIN + LOOP + BEGIN + PERFORM process_next(); + EXCEPTION + WHEN others THEN + RAISE NOTICE 'skipping bad record'; + END; + EXIT WHEN NOT FOUND; + END LOOP; + RETURN; +END" +`; + exports[`plpgsql-deparser bug fixes combined scenarios should handle PERFORM with record fields 1`] = ` "BEGIN PERFORM notify_change(NEW.id, NEW.status); @@ -179,3 +227,215 @@ BEGIN RETURN NEW; 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; + RETURN; +END" +`; + +exports[`plpgsql-deparser bug fixes deep nesting and sequential blocks should handle triple-nested blocks 1`] = ` +"BEGIN + BEGIN + BEGIN + PERFORM deep_call(); + END; + RAISE NOTICE 'middle'; + END; + RETURN; +END" +`; + +exports[`plpgsql-deparser bug fixes deep nesting and sequential blocks should handle two sequential nested blocks 1`] = ` +"BEGIN + BEGIN + PERFORM step_one(); + END; + BEGIN + PERFORM step_two(); + END; + RETURN; +END" +`; + +exports[`plpgsql-deparser bug fixes nested block compositions (END; bug class) should handle labeled nested block 1`] = ` +"BEGIN + <> + BEGIN + PERFORM do_work(); + END inner; + RETURN TRUE; +END" +`; + +exports[`plpgsql-deparser bug fixes nested block compositions (END; bug class) should handle nested block followed by IF 1`] = ` +"BEGIN + BEGIN + PERFORM setup_something(); + END; + IF FOUND THEN + RETURN TRUE; + END IF; + RETURN FALSE; +END" +`; + +exports[`plpgsql-deparser bug fixes nested block compositions (END; bug class) should handle nested block followed by PERFORM 1`] = ` +"DECLARE + v_count integer; +BEGIN + BEGIN + v_count := 42; + END; + PERFORM log_result(v_count); + RETURN v_count; +END" +`; + +exports[`plpgsql-deparser bug fixes nested block compositions (END; bug class) should handle nested block followed by RAISE 1`] = ` +"BEGIN + BEGIN + PERFORM risky_operation(); + END; + RAISE NOTICE 'Operation completed'; + RETURN; +END" +`; + +exports[`plpgsql-deparser bug fixes nested block compositions (END; bug class) should handle nested block followed by RETURN 1`] = ` +"DECLARE + v_result integer; +BEGIN + BEGIN + v_result := 1; + END; + RETURN v_result; +END" +`; + +exports[`plpgsql-deparser bug fixes nested block compositions (END; bug class) should handle nested block followed by assignment 1`] = ` +"DECLARE + v_status text; +BEGIN + BEGIN + PERFORM init(); + END; + v_status := 'complete'; + RETURN v_status; +END" +`; + +exports[`plpgsql-deparser bug fixes real-world patterns should handle multi-step sign-in pattern 1`] = ` +"DECLARE + v_user RECORD; + v_secret RECORD; +BEGIN + SELECT * INTO v_user FROM users WHERE email = v_email; + IF NOT FOUND THEN + RAISE EXCEPTION 'USER_NOT_FOUND'; + END IF; + SELECT * INTO v_secret FROM secrets WHERE user_id = v_user.id; + IF NOT FOUND THEN + RAISE EXCEPTION 'NO_CREDENTIALS'; + END IF; + IF v_secret.locked_at IS NOT NULL THEN + RAISE EXCEPTION 'ACCOUNT_LOCKED'; + END IF; + RETURN v_user; +END" +`; + +exports[`plpgsql-deparser bug fixes real-world patterns should handle permission bitnum trigger pattern 1`] = ` +"DECLARE + bitlen int; + v_len int; +BEGIN + v_len := 32; + BEGIN + bitlen := bit_length(NEW.bitstr); + EXCEPTION + WHEN others THEN + bitlen := 0; + END; + IF bitlen = 0 THEN + NEW.bitstr := lpad('', v_len, '0'); + END IF; + RETURN NEW; +END" +`; + +exports[`plpgsql-deparser bug fixes untested statement types should handle ASSERT statement 1`] = ` +"BEGIN + ASSERT p_x > 0, 'x must be positive'; + RETURN p_x; +END" +`; + +exports[`plpgsql-deparser bug fixes untested statement types should handle CALL statement 1`] = ` +"BEGIN + CALL my_procedure(1, 'hello'); + RETURN; +END" +`; + +exports[`plpgsql-deparser bug fixes untested statement types should handle FOR integer loop 1`] = ` +"BEGIN + FOR i IN 1..10 LOOP + PERFORM process(i); + END LOOP; + RETURN; +END" +`; + +exports[`plpgsql-deparser bug fixes untested statement types should handle FOR query loop 1`] = ` +"DECLARE + rec RECORD; +BEGIN + FOR rec IN SELECT id, name FROM my_table LOOP + PERFORM handle(rec); + END LOOP; + RETURN; +END" +`; + +exports[`plpgsql-deparser bug fixes untested statement types should handle RETURN NEXT with OUT parameters 1`] = ` +"BEGIN + FOR i IN 1..5 LOOP + x := i; + y := 'item_' || i::text; + RETURN NEXT; + END LOOP; + RETURN; +END" +`; + +exports[`plpgsql-deparser bug fixes untested statement types should handle RETURN QUERY 1`] = ` +"BEGIN + RETURN QUERY SELECT id, name FROM my_table WHERE active = TRUE; + RETURN; +END" +`; + +exports[`plpgsql-deparser bug fixes untested statement types should handle labeled FOR loop with EXIT 1`] = ` +"DECLARE + rec RECORD; +BEGIN + <> + FOR rec IN SELECT id, name FROM items LOOP + EXIT row_loop WHEN rec.id IS NULL; + PERFORM process(rec); + END LOOP row_loop; + RETURN; +END" +`; 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 15d0b27d..dd9ac82e 100644 --- a/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap @@ -151,7 +151,7 @@ BEGIN top_sku_qty = excluded.top_sku_qty, note = COALESCE(excluded.note, app_public.order_rollup.note), updated_at = now(); - GET DIAGNOSTICS v_rowcount = ; + 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; diff --git a/packages/plpgsql-deparser/__tests__/__snapshots__/schema-rename-mapped.test.ts.snap b/packages/plpgsql-deparser/__tests__/__snapshots__/schema-rename-mapped.test.ts.snap index 603f9af5..4246b2f3 100644 --- a/packages/plpgsql-deparser/__tests__/__snapshots__/schema-rename-mapped.test.ts.snap +++ b/packages/plpgsql-deparser/__tests__/__snapshots__/schema-rename-mapped.test.ts.snap @@ -236,7 +236,7 @@ CREATE FUNCTION myapp_v2.cleanup_old_sessions( deleted_count int; BEGIN DELETE FROM myapp_v2.sessions WHERE created_at < (now() - CAST(p_days || ' days' AS interval)); - GET DIAGNOSTICS deleted_count = ; + GET DIAGNOSTICS deleted_count = ROW_COUNT; RETURN deleted_count; END$$; diff --git a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts index 6d0ad1b8..4b068ff5 100644 --- a/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts +++ b/packages/plpgsql-deparser/__tests__/deparser-fixes.test.ts @@ -411,4 +411,465 @@ $$`; expect(deparsed).toMatchSnapshot(); }); }); + + // =========================================================================== + // Group 1: Nested Block Compositions (END; bug class) + // =========================================================================== + describe('nested block compositions (END; bug class)', () => { + it('should handle nested block followed by RETURN', async () => { + const sql = `CREATE FUNCTION test_nested_block_return() RETURNS integer +LANGUAGE plpgsql AS $$ +DECLARE + v_result integer; +BEGIN + BEGIN + v_result := 1; + END; + RETURN v_result; +END$$`; + + await testUtils.expectAstMatch('nested block + RETURN', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('END;'); + expect(deparsed).toContain('RETURN'); + }); + + it('should handle nested block followed by IF', async () => { + const sql = `CREATE FUNCTION test_nested_block_if() RETURNS boolean +LANGUAGE plpgsql AS $$ +BEGIN + BEGIN + PERFORM setup_something(); + END; + IF FOUND THEN + RETURN TRUE; + END IF; + RETURN FALSE; +END$$`; + + await testUtils.expectAstMatch('nested block + IF', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + + it('should handle nested block followed by RAISE', async () => { + const sql = `CREATE FUNCTION test_nested_block_raise() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + BEGIN + PERFORM risky_operation(); + END; + RAISE NOTICE 'Operation completed'; + RETURN; +END$$`; + + await testUtils.expectAstMatch('nested block + RAISE', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + + it('should handle nested block followed by PERFORM', async () => { + const sql = `CREATE FUNCTION test_nested_block_perform() RETURNS integer +LANGUAGE plpgsql AS $$ +DECLARE + v_count integer; +BEGIN + BEGIN + v_count := 42; + END; + PERFORM log_result(v_count); + RETURN v_count; +END$$`; + + await testUtils.expectAstMatch('nested block + PERFORM', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + + it('should handle nested block followed by assignment', async () => { + const sql = `CREATE FUNCTION test_nested_block_assign() RETURNS text +LANGUAGE plpgsql AS $$ +DECLARE + v_status text; +BEGIN + BEGIN + PERFORM init(); + END; + v_status := 'complete'; + RETURN v_status; +END$$`; + + await testUtils.expectAstMatch('nested block + assignment', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + + it('should handle labeled nested block', async () => { + const sql = `CREATE FUNCTION test_labeled_nested_block() RETURNS boolean +LANGUAGE plpgsql AS $$ +BEGIN + <> + BEGIN + PERFORM do_work(); + END inner; + RETURN TRUE; +END$$`; + + await testUtils.expectAstMatch('labeled nested block', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('<>'); + expect(deparsed).toMatch(/END\s+inner;/i); + }); + }); + + // =========================================================================== + // Group 2: Blocks Inside Control Structures + // =========================================================================== + describe('blocks inside control structures', () => { + it('should handle block inside IF THEN branch', async () => { + const sql = `CREATE FUNCTION test_block_in_if() RETURNS integer +LANGUAGE plpgsql AS $$ +BEGIN + IF 1 > 0 THEN + BEGIN + PERFORM positive_handler(); + EXCEPTION + WHEN others THEN + RAISE NOTICE 'error in positive handler'; + END; + ELSE + RETURN 0; + END IF; + RETURN 1; +END$$`; + + await testUtils.expectAstMatch('block in IF', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + + it('should handle block inside LOOP body', async () => { + const sql = `CREATE FUNCTION test_block_in_loop() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + LOOP + BEGIN + PERFORM process_next(); + EXCEPTION + WHEN others THEN + RAISE NOTICE 'skipping bad record'; + END; + EXIT WHEN NOT FOUND; + END LOOP; + RETURN; +END$$`; + + await testUtils.expectAstMatch('block in LOOP', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + + it('should handle block inside CASE WHEN', async () => { + const sql = `CREATE FUNCTION test_block_in_case(p_status text) RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + CASE p_status + WHEN 'retry' THEN + BEGIN + PERFORM retry_operation(); + EXCEPTION + WHEN others THEN + RAISE EXCEPTION 'retry failed'; + END; + WHEN 'skip' THEN + RAISE NOTICE 'skipping'; + END CASE; + RETURN; +END$$`; + + await testUtils.expectAstMatch('block in CASE', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + }); + + // =========================================================================== + // Group 3: Deep Nesting & Sequential Blocks + // =========================================================================== + describe('deep nesting and sequential blocks', () => { + it('should handle two sequential nested blocks', async () => { + const sql = `CREATE FUNCTION test_sequential_blocks() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + BEGIN + PERFORM step_one(); + END; + BEGIN + PERFORM step_two(); + END; + RETURN; +END$$`; + + await testUtils.expectAstMatch('sequential blocks', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + + it('should handle triple-nested blocks', async () => { + const sql = `CREATE FUNCTION test_triple_nested() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + BEGIN + BEGIN + PERFORM deep_call(); + END; + RAISE NOTICE 'middle'; + END; + RETURN; +END$$`; + + await testUtils.expectAstMatch('triple-nested blocks', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + + it('should handle block inside exception handler', async () => { + const sql = `CREATE FUNCTION test_block_in_exception() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + PERFORM risky(); +EXCEPTION + WHEN others THEN + BEGIN + PERFORM log_error(); + EXCEPTION + WHEN others THEN + RAISE NOTICE 'even logging failed'; + END; +END$$`; + + await testUtils.expectAstMatch('block in exception handler', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + }); + + // =========================================================================== + // Group 4: Untested Statement Types + // =========================================================================== + describe('untested statement types', () => { + it('should handle FOR integer loop', async () => { + const sql = `CREATE FUNCTION test_for_integer_loop() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + FOR i IN 1..10 LOOP + PERFORM process(i); + END LOOP; + RETURN; +END$$`; + + await testUtils.expectAstMatch('FOR integer loop', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + + it('should handle FOR query loop', async () => { + const sql = `CREATE FUNCTION test_for_query_loop() RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE + rec record; +BEGIN + FOR rec IN SELECT id, name FROM my_table LOOP + PERFORM handle(rec); + END LOOP; + RETURN; +END$$`; + + await testUtils.expectAstMatch('FOR query loop', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + + it('should handle labeled FOR loop with EXIT', async () => { + const sql = `CREATE FUNCTION test_labeled_for_loop() RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE + rec record; +BEGIN + <> + FOR rec IN SELECT id, name FROM items LOOP + EXIT row_loop WHEN rec.id IS NULL; + PERFORM process(rec); + END LOOP row_loop; + RETURN; +END$$`; + + await testUtils.expectAstMatch('labeled FOR loop', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('<>'); + }); + + it('should handle RETURN NEXT with OUT parameters', async () => { + const sql = `CREATE FUNCTION test_return_next_out(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$$`; + + await testUtils.expectAstMatch('RETURN NEXT', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('RETURN NEXT'); + }); + + it('should handle RETURN QUERY', async () => { + const sql = `CREATE FUNCTION test_return_query_simple() RETURNS SETOF record +LANGUAGE plpgsql AS $$ +BEGIN + RETURN QUERY SELECT id, name FROM my_table WHERE active = TRUE; +END$$`; + + await testUtils.expectAstMatch('RETURN QUERY', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('RETURN QUERY'); + }); + + it('should handle ASSERT statement', async () => { + const sql = `CREATE FUNCTION test_assert(p_x integer) RETURNS integer +LANGUAGE plpgsql AS $$ +BEGIN + ASSERT p_x > 0, 'x must be positive'; + RETURN p_x; +END$$`; + + await testUtils.expectAstMatch('ASSERT', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('ASSERT'); + }); + + it('should handle CALL statement', async () => { + const sql = `CREATE FUNCTION test_call_statement() RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + CALL my_procedure(1, 'hello'); + RETURN; +END$$`; + + await testUtils.expectAstMatch('CALL statement', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + expect(deparsed).toContain('CALL'); + }); + }); + + // =========================================================================== + // Group 5: Real-World Patterns + // =========================================================================== + describe('real-world patterns', () => { + it('should handle permission bitnum trigger pattern', async () => { + const sql = `CREATE FUNCTION test_permission_bitnum_trigger() RETURNS trigger +LANGUAGE plpgsql AS $$ +DECLARE + bitlen int; + v_len int; +BEGIN + v_len := 32; + BEGIN + bitlen := bit_length(NEW.bitstr); + EXCEPTION + WHEN others THEN + bitlen := 0; + END; + IF bitlen = 0 THEN + NEW.bitstr := lpad('', v_len, '0'); + END IF; + RETURN NEW; +END$$`; + + await testUtils.expectAstMatch('permission bitnum trigger', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + // Verify the nested block with exception is properly terminated + expect(deparsed).toContain('EXCEPTION'); + expect(deparsed).toContain('RETURN NEW'); + }); + + it('should handle multi-step sign-in pattern', async () => { + const sql = `CREATE FUNCTION test_signin_pattern(v_email text) RETURNS record +LANGUAGE plpgsql AS $$ +DECLARE + v_user record; + v_secret record; +BEGIN + SELECT * INTO v_user FROM users WHERE email = v_email; + IF NOT FOUND THEN + RAISE EXCEPTION 'USER_NOT_FOUND'; + END IF; + SELECT * INTO v_secret FROM secrets WHERE user_id = v_user.id; + IF NOT FOUND THEN + RAISE EXCEPTION 'NO_CREDENTIALS'; + END IF; + IF v_secret.locked_at IS NOT NULL THEN + RAISE EXCEPTION 'ACCOUNT_LOCKED'; + END IF; + RETURN v_user; +END$$`; + + await testUtils.expectAstMatch('signin pattern', sql); + + const parsed = parsePlPgSQLSync(sql) as unknown as PLpgSQLParseResult; + const deparsed = deparseSync(parsed); + expect(deparsed).toMatchSnapshot(); + }); + }); }); 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 fa462a99..2f144a35 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 @@ -133,7 +133,7 @@ begin top_sku_qty = EXCLUDED.top_sku_qty, note = COALESCE(EXCLUDED.note, app_public.order_rollup.note), updated_at = now(); - get diagnostics v_rowcount = ; + 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', @@ -362,7 +362,7 @@ BEGIN top_sku_qty = EXCLUDED.top_sku_qty, note = COALESCE(EXCLUDED.note, app_public.order_rollup.note), updated_at = now(); - GET DIAGNOSTICS v_rowcount = ; + 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', diff --git a/packages/plpgsql-deparser/src/plpgsql-deparser.ts b/packages/plpgsql-deparser/src/plpgsql-deparser.ts index 13afe071..8a88fbbf 100644 --- a/packages/plpgsql-deparser/src/plpgsql-deparser.ts +++ b/packages/plpgsql-deparser/src/plpgsql-deparser.ts @@ -2034,9 +2034,31 @@ export class PLpgSQLDeparser { /** * Get the diagnostic item kind name */ - private getDiagItemKindName(kind: number | undefined): string { + private getDiagItemKindName(kind: number | string | undefined): string { if (kind === undefined) return ''; + // The parser may return kind as a string name (e.g. "ROW_COUNT") or as a + // numeric enum value. Handle both forms so the deparser works regardless. + if (typeof kind === 'string') { + // Map string names to their SQL keyword equivalents + const stringMap: Record = { + 'ROW_COUNT': 'ROW_COUNT', + 'PG_CONTEXT': 'PG_CONTEXT', + 'PG_EXCEPTION_CONTEXT': 'PG_EXCEPTION_CONTEXT', + 'PG_EXCEPTION_DETAIL': 'PG_EXCEPTION_DETAIL', + 'PG_EXCEPTION_HINT': 'PG_EXCEPTION_HINT', + 'RETURNED_SQLSTATE': 'RETURNED_SQLSTATE', + 'COLUMN_NAME': 'COLUMN_NAME', + 'CONSTRAINT_NAME': 'CONSTRAINT_NAME', + 'PG_DATATYPE_NAME': 'PG_DATATYPE_NAME', + 'MESSAGE_TEXT': 'MESSAGE_TEXT', + 'TABLE_NAME': 'TABLE_NAME', + 'SCHEMA_NAME': 'SCHEMA_NAME', + }; + const mapped = stringMap[kind]; + return mapped ? this.keyword(mapped) : ''; + } + switch (kind) { case DiagItemKind.PLPGSQL_GETDIAG_ROW_COUNT: return this.keyword('ROW_COUNT'); diff --git a/packages/plpgsql-deparser/test-utils/index.ts b/packages/plpgsql-deparser/test-utils/index.ts index 9503d5c0..e6d856ee 100644 --- a/packages/plpgsql-deparser/test-utils/index.ts +++ b/packages/plpgsql-deparser/test-utils/index.ts @@ -207,17 +207,31 @@ export const transform = (obj: any, props: any): any => { throw new Error("Unable to copy obj! Its type isn't supported."); }; +// Props object for cleanPlpgsqlTree, defined as a variable so the query handler +// can reference it for recursive descent when 'query' is an object (e.g. +// PLpgSQL_stmt_return_query.query wraps a PLpgSQL_expr, while PLpgSQL_expr.query +// is the actual SQL string). +const cleanProps: Record = { + lineno: noop, + location: noop, + stmt_len: noop, + stmt_location: noop, + // varno values are assigned based on position in datums array and can change + // when implicit variables (like sqlstate/sqlerrm) are filtered out during deparse + varno: noop, + query: (val: any): any => { + if (typeof val === 'string') { + return normalizeQueryWhitespace(val); + } + // Not a string (e.g. PLpgSQL_stmt_return_query.query is an object wrapping + // PLpgSQL_expr) — continue recursing so the inner PLpgSQL_expr.query string + // gets normalized. + return transform(val, cleanProps); + }, +}; + export const cleanPlpgsqlTree = (tree: any) => { - return transform(tree, { - lineno: noop, - location: noop, - stmt_len: noop, - stmt_location: noop, - // varno values are assigned based on position in datums array and can change - // when implicit variables (like sqlstate/sqlerrm) are filtered out during deparse - varno: noop, - query: normalizeQueryWhitespace, - }); + return transform(tree, cleanProps); }; type ParseErrorType =