Skip to content

Fix INTO semantics in plpgsql - #3237

Merged
reltuk merged 1 commit into
mainfrom
aaron/into-fixes
Sep 1, 2026
Merged

Fix INTO semantics in plpgsql#3237
reltuk merged 1 commit into
mainfrom
aaron/into-fixes

Conversation

@reltuk

@reltuk reltuk commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

An INTO clause without STRICT has three behaviors that Doltgres got wrong. A query matching no rows raised instead of setting every target to NULL. A query matching several rows raised instead of keeping the first and discarding the rest. And a target had to have exactly the type the query column produced, rather than accepting anything with an assignment cast to it.

An INTO naming several variables also assigned only the first of them, because the loop that walked the targets was indexed by row rather than by target.

QueryRowReturn replaces the two paths that INTO used to take, so a single target and several targets are now handled the same way, and reports whether the query produced a row at all. The casting that QuerySingleReturn did inline is extracted as castQueryValue so both share it.

An INTO clause without STRICT has three behaviors that Doltgres got
wrong. A query matching no rows raised instead of setting every target
to NULL. A query matching several rows raised instead of keeping the
first and discarding the rest. And a target had to have exactly the
type the query column produced, rather than accepting anything with an
assignment cast to it.

An INTO naming several variables also assigned only the first of them,
because the loop that walked the targets was indexed by row rather
than by target.

QueryRowReturn replaces the two paths that INTO used to take, so a
single target and several targets are now handled the same way, and
reports whether the query produced a row at all. The casting that
QuerySingleReturn did inline is extracted as castQueryValue so both
share it.
@reltuk
reltuk requested a review from Hydrocharged August 31, 2026 15:52
@github-actions

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

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: 1a88799: 14 test cases ran, 1 failed ❌, 12 passed ✅, 1 additional finding ⚠️.

Summary

The run covers core database function behavior across ordinary assignments, NULL and empty-result handling, type conversion, multi-value targeting, repeated queries, dynamic queries, and invalid input shapes. It also exercises adversarial error paths and strict result-count behavior, providing broad business-logic and edge-case coverage.

Merge with caution — this PR still has a medium-severity correctness issue in strict query cardinality handling, allowing invalid zero- or multi-row results to succeed instead of raising errors. A separate high-severity parameter-expression and NULL-condition issue is not attributable to this PR and remains a flag for later.

Tests run by Ito

View full run

Result Severity Type Description
Medium severity Rev The no-row strict query returned NULL instead of a no-data error, and the multi-row strict query returned the first value instead of a too-many-rows error.
General The database returned the expected values for ordinary inputs, NULL inputs, and a value that needed conversion. Each target received its own value without mixing the two columns.
General Queries with too many columns, rows, or values were rejected with clear errors. The next valid call succeeded for each case, so the earlier errors did not leave the session in a bad state.
General The database kept the first row from the first multi-row query, cleared both values when the next query found no rows, and used the new row on the final query.
Cast The function succeeded and converted the integer 7 to text and the text value '42' to integer 42.
Cast Assigning an integer to a boolean target stops with the expected cast error instead of accepting an invalid value.
First A function using non-STRICT SELECT INTO succeeded and returned id 1, the first row in ascending order, while discarding the later row.
Null A database function ran successfully when its lookup found no rows, and both saved values were cleared to NULL.
Rev Verified acceptable by independent adversarial review: the reported expectation does not match what the code actually promises. Review notes: The finding invents an unconditional PL/pgSQL arity-error contract for an empty result. The repository's PostgreSQL regression corpus treats multi-assignment mismatch diagnostics as optional and explicitly says their check is active only for a non-empty source, while the PR deliberately makes non-STRICT no-row INTO assign NULL; therefore the observed NULL result is intended compatibility behavior,…
Rev A dynamic query filled both target values for an existing record and set both values to NULL when no record matched.
Single A query with one column and one row assigned the value 1 successfully.
Target The database function returned 1 and a in the same order as the two selected columns.
Target A function with two named targets was called with a query that returned one column, and the database returned the expected INTO column-count error before assigning values.
⚠️ High severity General The function could not use its parameter in shared expressions. Valid and post-error calls returned a column-not-found error, and the NULL conditional call panicked instead of taking the false branch.
Additional Findings Details

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

🟠 PL/pgSQL parameters fail inside expressions
  • Severity: High High severity
  • Description: The function could not use its parameter in shared expressions. Valid and post-error calls returned a column-not-found error, and the NULL conditional call panicked instead of taking the false branch.
  • Impact: PL/pgSQL functions can fail when they use a parameter in common expressions, so normal database operations may return errors instead of results. A NULL condition can also crash the server process.
  • Steps to Reproduce:
    1. Create a local PL/pgSQL function with a text parameter and use that parameter in a scalar assignment, an IF condition, a return expression, and a notice expression.
    2. Call the function with a valid value such as 7.
    3. Call it with NULL, an incompatible value such as bad, and then the valid value again.
    4. Observe that valid and post-error calls report that the parameter column cannot be found, while the NULL conditional path reports a server panic.
  • Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code Analysis: Expression consumers call InterpretedFunction.QuerySingleReturn with operation.SecondaryData as the binding list: assignment does this at server/plpgsql/interpreter_logic.go:117-128, IF does it at lines 266-271, and RETURN does it at lines 331-349. QuerySingleReturn first delegates to ApplyBindings at server/functions/framework/interpreted_function.go:174-178, then executes the resulting statement through QueryWithBindings at lines 180-198. ApplyBindings only replaces $N placeholders for names that are successfully found in the interpreter stack at lines 303-343. The parser-side substitution in server/plpgsql/statements.go:496-545 is responsible for recording parameter references and producing those placeholders; the reproduction shows that the consumer statement still reaches SQL with the parameter unresolved, producing the column-not-found error. Separately, the IF path at server/plpgsql/interpreter_logic.go:267-271 asserts retVal as bool without checking for nil, so a NULL result reaches retVal.(bool) and causes the observed interface-conversion panic. The PR diff extracts castQueryValue from QuerySingleReturn and adds QueryRowReturn for INTO handling, but does not show a direct change that fixes parameter reference recording or the nil guard in IF. The smallest practical remediation is to fix the parameter-reference substitution/binding handoff so every affected consumer receives the recorded value, and to handle a NULL IF result as false before the bool assertion.
Evidence Package

Tip

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

Comment thread server/functions/framework/interpreted_function.go
@coffeegoddd

Copy link
Copy Markdown
Contributor

@reltuk DOLT

read_tests from_latency to_latency percent_change
covering_index_scan_postgres 2.48 2.48 0.0
groupby_scan_postgres 77.19 77.19 0.0
index_join_postgres 2.22 2.22 0.0
index_join_scan_postgres 1.58 1.58 0.0
index_scan_postgres 493.24 484.44 -1.78
oltp_point_select 0.37 0.36 -2.7
oltp_read_only 6.32 6.32 0.0
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 1235.62 1213.57 -1.78
write_tests from_latency to_latency percent_change
oltp_delete_insert_postgres 6.67 6.67 0.0
oltp_insert 3.36 3.3 -1.79
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 7.04 6.91 -1.85
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 30e58ef into main Sep 1, 2026
31 of 32 checks passed
@reltuk
reltuk deleted the aaron/into-fixes branch September 1, 2026 14:58
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