Skip to content

server/plpgsql: Support FOREACH IN ARRAY. - #3402

Merged
reltuk merged 1 commit into
mainfrom
aaron/pgpsql-foreach-in-array
Sep 18, 2026
Merged

reltuk merged 1 commit into
mainfrom
aaron/pgpsql-foreach-in-array

Conversation

@reltuk

@reltuk reltuk commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

The loop's expression is evaluated once into a record, which unnest then visits in order, assigning each element to the loop variable. An expression that does not yield an array, or that yields a null array, raises as PostgreSQL does. SLICE is not yet supported.

The loop's expression is evaluated once into a record, which unnest then
visits in order, assigning each element to the loop variable. An
expression that does not yield an array, or that yields a null array,
raises as PostgreSQL does. SLICE is not yet supported.
@reltuk
reltuk requested a review from Hydrocharged September 17, 2026 12:43
@github-actions

Copy link
Copy Markdown
Contributor
Main PR
Total 42090 42090
Successful 19888 19894
Failures 22202 22196
Partial Successes1 5437 5438
Main PR
Successful 47.2511% 47.2654%
Failures 52.7489% 52.7346%

${\color{lightgreen}Progressions (6)}$

insert_conflict

QUERY: insert into insertconflicttest values (26, 'Peach') on conflict (key) do update set fruit = excluded.fruit;

plpgsql

QUERY: create function foreach_test(anyarray)
returns void as $$
declare x int;
begin
  foreach x in array $1
  loop
    raise notice '%', x;
  end loop;
  end;
$$ language plpgsql;
QUERY: create or replace function foreach_test(anyarray)
returns void as $$
declare r record;
begin
  foreach r in array $1
  loop
    raise notice '%', r;
  end loop;
  end;
$$ language plpgsql;
QUERY: create or replace function foreach_test(anyarray)
returns void as $$
declare x int; y int;
begin
  foreach x, y in array $1
  loop
    raise notice 'x = %, y = %', x, y;
  end loop;
  end;
$$ language plpgsql;
QUERY: drop function foreach_test(anyarray);

subselect

QUERY: select count(*) from tenk1 t
where (exists(select 1 from tenk1 k where k.unique1 = t.unique2) or ten < 0);

Footnotes

  1. These are tests that we're marking as Successful, however they do not match the expected output in some way. This is due to small differences, such as different wording on the error messages, or the column names being incorrect while the data itself is correct.

@itoqa

itoqa Bot commented Sep 17, 2026

Copy link
Copy Markdown

Ito QA test results
Commit: ca007b9: 19 test cases ran, 1 failed ❌, 17 passed ✅, 1 additional finding ⚠️.

Summary

The run covers core database loop behavior across normal array and query processing, ordering, repeated and concurrent calls, error recovery, trigger-driven updates, and boundary conditions. Most exercised flows remain healthy, but nested-loop handling has a functional gap for valid usage with shared loop variables.

Merge with caution — the PR introduces a medium-severity failure that breaks valid nested database loops, making affected applications unable to produce results until corrected. A separate medium-severity labeled-exit issue is unrelated to this PR and is a flag for later rather than a merge driver.

Tests run by Ito

View full run

Result Severity Type Description
Medium severity Scope Calling the nested-loop function raises record variable (unnamed row) could not be found and returns no combinations, although the expected result contains four nested iterations.
General A one-row query loop processed the row once, then ended normally and returned the after-loop marker.
General The row that already had a retirement time was rejected and stayed unchanged. A second row accepted its first retirement time and kept its note.
General The first query returned its division-by-zero error, and the next query on the same connection returned the complete rows 1,2.
General The rejected update returned the trigger error and left its row unchanged. A later update to another row succeeded and stored the correct timestamp and status.
General The function returned all three table values in order as 3:red,green,blue.
General The server returned the expected error for each invalid FOREACH call, and the same client connection successfully ran a valid loop afterward.
General The loop returned abc from the original table value, while the later table read showed {mutated}.
General Two overlapping database calls each returned its own complete rows in order: A1,A2 and B1,B2.
General Three quick calls each returned one, the null marker, and three in the correct order.
General The first update showed the trigger error, and the next update on the same connection succeeded and stored its new timestamp.
Foreach The function returned alpha, beta, and gamma in the same order as the input array.
Query The function returned both table rows in the same order as the query, showing that each row reached the loop body correctly.
Reject The function was rejected before the loop ran with the explicit message that FOREACH with SLICE is not yet supported.
Rev The loop skipped the middle row and returned 13 on both calls, so CONTINUE advanced to the next row and did not leave stale cursor state.
Rev A populated array returned true after the loop exited early, and an empty array returned false.
Rev The invalid FOREACH call returned error 42804, then the same session returned 42 and completed a valid FOREACH function with result 3.
Trigger The row update succeeded, and the trigger stored the new timestamp while reading the old and new row values.
⚠️ Medium severity General The labeled outer-exit function was rejected during creation with unhandled statement type: plpgsql.statement, so its counters, trace, and FOUND value were never produced.
Additional Findings Details

These findings are unrelated to the current changes but were observed during testing.

🟡 Labeled loop exit rejects valid functions
  • Severity: Medium Medium severity
  • Description: The labeled outer-exit function was rejected during creation with unhandled statement type: plpgsql.statement, so its counters, trace, and FOUND value were never produced.
  • Impact: Users who create functions with a labeled outer exit cannot create or run those functions. Other database functions are not shown to be affected, and there is no evidence of data loss or corruption.
  • Steps to Reproduce:
    1. Create the tags table with rows 1 and 2.
    2. Create a PL/pgSQL function with an outer labeled FOR loop, an inner FOR loop, and EXIT outer_loop WHEN outer_value = 1 AND inner_value = 1.
    3. Observe that CREATE FUNCTION fails before SELECT can invoke the function.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: The failing SQL is converted during function creation by jsonConvertStatement in server/plpgsql/json_convert.go. That dispatcher handles known statement arms and returns errors.Errorf("unhandled statement type: %T", stmt) at lines 129-174 when the decoded statement has no recognized arm; the observed %T is plpgsql.statement, matching this fallback. Consequently, execution never reaches the loop runtime. The intended labeled-exit runtime path is implemented by plpgSQL_stmt_exit.Convert at server/plpgsql/json.go:515-547, which emits a labeled Goto, and by reconcileLabels plus exitScope in server/plpgsql/reconcile_labels.go:37-105 and server/plpgsql/interpreter_logic.go:608-623, which should tear down inner scopes and update FOUND while jumping to the outer scope. The PR changed cursor ownership in server/plpgsql/interpreter_logic.go at the ForQueryInit/ForQueryNext calls and removed named cursor cleanup in exitScope, but those runtime lines are not reached when conversion rejects the function. The smallest practical fix is to make the JSON statement decoding/conversion preserve and dispatch the labeled EXIT statement shape emitted for this function, then add or retain a focused CREATE FUNCTION regression assertion before validating cursor unwinding.
Evidence Package

Tip

Reply with @itoqa to send us feedback on this test run.

Comment thread server/plpgsql/json.go
@coffeegoddd

Copy link
Copy Markdown
Contributor

@reltuk DOLT

read_tests from_latency to_latency percent_change
covering_index_scan_postgres 2.66 2.61 -1.88
groupby_scan_postgres 80.03 80.03 0.0
index_join_postgres 2.26 2.3 1.77
index_join_scan_postgres 1.64 1.64 0.0
index_scan_postgres 475.79 467.3 -1.78
oltp_point_select 0.37 0.37 0.0
oltp_read_only 6.55 6.55 0.0
select_random_points 0.73 0.73 0.0
select_random_ranges 1.04 1.04 0.0
table_scan_postgres 475.79 475.79 0.0
types_table_scan_postgres 1191.92 1213.57 1.82
write_tests from_latency to_latency percent_change
oltp_delete_insert_postgres 6.79 6.67 -1.77
oltp_insert 3.36 3.36 0.0
oltp_read_write 13.7 13.7 0.0
oltp_update_index 3.62 3.62 0.0
oltp_update_non_index 3.3 3.3 0.0
oltp_write_only 7.17 7.04 -1.81
types_delete_insert_postgres 7.3 7.17 -1.78

@Hydrocharged Hydrocharged left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@reltuk
reltuk merged commit d40f7bc into main Sep 18, 2026
29 checks passed
@reltuk
reltuk deleted the aaron/pgpsql-foreach-in-array branch September 18, 2026 09:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants