Skip to content

Fix CONTINUE statement in plpgsql FOR ... IN ... SELECT loops and integer FOR loops. - #3238

Merged
reltuk merged 2 commits into
mainfrom
aaron/plpgsql-fori-continue
Sep 1, 2026
Merged

Fix CONTINUE statement in plpgsql FOR ... IN ... SELECT loops and integer FOR loops.#3238
reltuk merged 2 commits into
mainfrom
aaron/plpgsql-fori-continue

Conversation

@reltuk

@reltuk reltuk commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

No description provided.

reltuk added 2 commits August 31, 2026 17:43
CONTINUE resolved to the first operation inside the enclosing loop's
scope, which for an integer FOR loop is the assignment of its lower
bound, so CONTINUE restarted the loop instead of advancing it and never
terminated. A loop now records its next-iteration operation on its
ScopeBegin operation, and reconcileLabels resolves both bare and
labelled CONTINUE statements through it, leaving WHILE and plain LOOP on
the target that is already correct for them. The integer FOR loop's
increment moves ahead of its condition test so that one operation both
advances the loop and falls into that test.
CONTINUE resolved to the ForQueryInit that runs the query and fills the
cursor, so it re-ran the query and reset the cursor instead of fetching
the next row, and never terminated. It now targets the ForQueryNext that
advances the cursor.
@reltuk
reltuk requested a review from Hydrocharged August 31, 2026 15:53
@github-actions

Copy link
Copy Markdown
Contributor
Main PR
Total 42090 42090
Successful 19270 19271
Failures 22820 22819
Partial Successes1 5459 5459
Main PR
Successful 45.7828% 45.7852%
Failures 54.2172% 54.2148%

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

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 Aug 31, 2026

Copy link
Copy Markdown

Ito QA test results
Commit: 4c7e192: 14 test cases ran, 12 passed ✅, 2 additional findings ⚠️.

Summary

Coverage spans core database loop behavior, including forward and reverse counting, conditional skips, nested and labeled control flow, cursor exhaustion, boundary handling, malformed constructs, and recovery after errors. Happy paths and edge cases behave normally, while invalid scalar targets and unsafe loop-step values expose pre-existing robustness gaps.

Safe to merge — neither observed failure is attributable to this PR, and no regression, new failure, or previously flagged unresolved failure was found. The unrelated issues are important follow-up defects, including a medium-impact hang risk, but they are not merge blockers for this change.

Tests run by Ito

View full run

Result Severity Type Description
General Ascending and reverse loops included the final value when the step landed exactly on the bound, and stopped after the bound was crossed.
General Empty, one-row, and multi-row queries finished normally. Each row was fetched at most once, and continuing on the last row did not repeat data or keep the loop running.
General A malformed function definition was rejected, and a later nested loop worked normally in the same session and in a fresh session.
General The integration checks passed for ordered query loops, nested loops, labelled loops, final-row continuation, and loop guard termination. Rows advanced in order and the loop ended normally after the cursor was exhausted.
General The nested loops finished normally, and the version using labelled skips matched the version using direct exits at 266:6.
Continue The nested loop returned 1:one, 2:two, and 3:three, then stopped after six inner-row visits. The labelled CONTINUE advanced the outer loop without restarting the inner query.
Continue Creating a function with CONTINUE aimed at an ordinary block fails with a clear error, and no function is created.
Cursor The query loop returned rows 1, 3, and 4 once each after skipping row 2.
Integer The loop returned 1, 3, and 5 once each, skipped the even values, and finished normally.
Integer The reverse loop returned 6, 4, and 2 exactly once, then stopped at the lower bound.
Loop The function skipped the value 3, kept moving forward, and returned the expected total of 12.
Loop The function continued past the skipped counter value, stopped at the exit condition, and returned 5 as expected.
⚠️ Medium severity General The record target reaches the second row and returns 20:second, and a control loop without CONTINUE sees both rows. The scalar target fails with record variable (unnamed row) could not be found, while the expected behavior is to assign each selected scalar value to the declared scalar variable and continue the loop.
⚠️ Medium severity Rev The database accepts invalid loop steps. A zero step and a negative step in the tested direction leave the loop unable to reach its end, so the function call hangs instead of failing during function creation or invocation.
Additional Findings Details

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

🟡 Scalar loop targets fail during iteration
  • Severity: Medium Medium severity
  • Description: The record target reaches the second row and returns 20:second, and a control loop without CONTINUE sees both rows. The scalar target fails with record variable (unnamed row) could not be found, while the expected behavior is to assign each selected scalar value to the declared scalar variable and continue the loop.
  • Impact: Users who use a scalar target in a query loop receive an error instead of processing the selected rows. They can use a record target instead, but the supported scalar form does not work.
  • Steps to Reproduce:
    1. Create a table with two rows, such as (10, 'first') and (20, 'second').
    2. Declare an integer variable and use it as the target of FOR x IN SELECT id FROM the table ORDER BY id LOOP.
    3. Use CONTINUE for the first row, then run the function locally.
    4. Observe the error that the unnamed row record variable could not be found instead of the second row being assigned to the integer variable.
  • Stub / mock content: The test used a local Doltgres instance and temporary SQL fixtures and functions; no application mocks, route interception, or bypasses were applied.
  • Code Analysis: The parser/converter accepts all three target forms in server/plpgsql/json.go:632-642, selecting a name from stmt.Var.Record, stmt.Var.Variable, or stmt.Var.Row. It then emits the same ForQueryNext operation for every form at server/plpgsql/json.go:664-667; that operation stores only the target name and has no target-kind information. At server/plpgsql/interpreter_logic.go:379-388, every ForQueryNext result is unconditionally passed to stack.UpdateRecord. UpdateRecord in server/plpgsql/interpreter_stack.go:438-451 only searches stack entries as record variables, marks a matching entry as a record, and otherwise returns record variable ... could not be found. Scalar loop variables therefore cannot receive the fetched row, which directly explains the local unnamed-row error; the typed-row invocation is likewise rejected by this incomplete target handling. The smallest fix is to preserve the parsed target kind in the loop operation and dispatch scalar and row targets to appropriate assignment logic instead of always calling UpdateRecord, with focused tests for scalar and row targets. The PR diff does not alter this FOR ... IN ... SELECT path, so this finding is pre-existing relative to the PR.
Evidence Package
🟡 Invalid loop steps can hang the database
  • Severity: Medium Medium severity
  • Description: The database accepts invalid loop steps. A zero step and a negative step in the tested direction leave the loop unable to reach its end, so the function call hangs instead of failing during function creation or invocation.
  • Impact: A database user who runs a loop with an invalid step can have the query hang instead of receiving an error. The query may tie up that database connection until it is canceled, but valid queries still work afterward.
  • Steps to Reproduce:
    1. Create a PL/pgSQL function with an ascending integer FOR loop using BY 0, such as FOR i IN 1..3 BY 0 LOOP NULL; END LOOP.
    2. Call the function in a local Doltgres session and wait for the call to finish.
    3. Repeat with a REVERSE loop using BY -1, then create and call a valid positive-step loop in a fresh or recovered local session.
    4. Observe that both invalid calls keep running instead of returning a validation error, while the valid control call returns normally.
  • Stub / mock content: The test used a locally running Doltgres database with local test credentials. No stubs, mocks, route interception, or application bypasses were applied.
  • Code Analysis: In server/plpgsql/json.go:546-558, plpgSQL_stmt_fori.Convert extracts stmt.Step.Expression.Query as stepExpr but never evaluates or validates whether the supplied step is zero or has the correct sign for the loop direction. At lines 563-568 it builds the reverse increment as the loop variable minus step and the ascending increment as the loop variable plus step. The generated loop then places that increment at lines 602-605, tests the bound at lines 606-609, and jumps back to the increment at lines 615-618. Therefore an ascending loop with BY 0 repeatedly assigns the same value and continues to satisfy the <= bound; a REVERSE loop with BY -1 subtracts a negative value, increasing the variable while the >= bound remains true. The local SQL evidence reproduced both hangs, including a client timeout over 120 seconds for BY 0 and an 8-second timeout for BY -1, while a valid BY 2 function returned 9 and a later SELECT 1 completed. The smallest practical fix is to validate the integer FOR step before emitting executable loop operations: reject zero, reject negative steps for ascending loops, and reject negative steps for REVERSE loops with the same clear error contract expected by PostgreSQL. If the step is a runtime expression, the generated function execution path should perform that check before entering the loop rather than relying on the operation layout.
Evidence Package

Tip

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

@coffeegoddd

Copy link
Copy Markdown
Contributor

@reltuk DOLT

read_tests from_latency to_latency percent_change
covering_index_scan_postgres 2.48 2.52 1.61
groupby_scan_postgres 77.19 75.82 -1.77
index_join_postgres 2.22 2.22 0.0
index_join_scan_postgres 1.58 1.58 0.0
index_scan_postgres 484.44 484.44 0.0
oltp_point_select 0.36 0.36 0.0
oltp_read_only 6.21 6.32 1.77
select_random_points 0.7 0.7 0.0
select_random_ranges 1.01 1.01 0.0
table_scan_postgres 484.44 484.44 0.0
types_table_scan_postgres 1213.57 1213.57 0.0
write_tests from_latency to_latency percent_change
oltp_delete_insert_postgres 6.67 6.67 0.0
oltp_insert 3.3 3.3 0.0
oltp_read_write 13.22 13.22 0.0
oltp_update_index 3.55 3.55 0.0
oltp_update_non_index 3.25 3.25 0.0
oltp_write_only 6.91 7.04 1.88
types_delete_insert_postgres 7.17 7.17 0.0

@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 fe7c261 into main Sep 1, 2026
29 checks passed
@reltuk
reltuk deleted the aaron/plpgsql-fori-continue branch September 1, 2026 14:39
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