From d26ec8a1f705bf7fec8f08f34352d149fdaf2ba2 Mon Sep 17 00:00:00 2001 From: Aaron Son Date: Tue, 1 Sep 2026 17:11:16 +0200 Subject: [PATCH] Support the built-in FOUND variable in plpgsql FOUND is declared for every function and set by the statements PostgreSQL defines as setting it: INTO clauses, bare DML, PERFORM, FOR..IN..SELECT, RETURN QUERY, and both kinds of FOR loop on exit. A FOR loop reports whether its body ran at all, which it records on its own scope rather than in FOUND, since PostgreSQL leaves FOUND alone while the loop is running. Every way of leaving a scope now routes through a single exitScope, so a loop reports FOUND and closes its cursor whether it ends normally, by EXIT, or by a labelled CONTINUE of an outer loop. A dynamic EXECUTE and a utility statement such as CREATE TABLE deliberately leave FOUND alone, so statements are classified at conversion time and the opcode that dynamic execution shares with static execution is told apart by an operation option. --- server/plpgsql/interpreter_logic.go | 105 +++- server/plpgsql/interpreter_operation.go | 11 + server/plpgsql/interpreter_stack.go | 49 +- server/plpgsql/json.go | 9 +- server/plpgsql/json_convert.go | 20 + server/plpgsql/statements.go | 42 +- testing/go/plpgsql_found_test.go | 761 ++++++++++++++++++++++++ 7 files changed, 982 insertions(+), 15 deletions(-) create mode 100644 testing/go/plpgsql_found_test.go diff --git a/server/plpgsql/interpreter_logic.go b/server/plpgsql/interpreter_logic.go index 992890a692..31eaac825b 100644 --- a/server/plpgsql/interpreter_logic.go +++ b/server/plpgsql/interpreter_logic.go @@ -22,6 +22,7 @@ import ( "github.com/cockroachdb/errors" "github.com/dolthub/dolt/go/libraries/doltcore/sqle/dsess" "github.com/dolthub/go-mysql-server/sql" + gmstypes "github.com/dolthub/go-mysql-server/sql/types" "github.com/jackc/pgx/v5/pgproto3" "github.com/dolthub/doltgresql/core/id" @@ -217,11 +218,21 @@ func call(ctx *sql.Context, iFunc InterpretedFunction, stack InterpreterStack) ( return nil, err } } + if setsFound(operation) { + if err = stack.SetFound(ctx, rowFound); err != nil { + return nil, err + } + } } else { - _, _, err := iFunc.QueryMultiReturn(ctx, stack, operation.PrimaryData, operation.SecondaryData) + _, rows, err := iFunc.QueryMultiReturn(ctx, stack, operation.PrimaryData, operation.SecondaryData) if err != nil { return nil, err } + if setsFound(operation) { + if err = stack.SetFound(ctx, queryProducedRow(rows)); err != nil { + return nil, err + } + } } case OpCode_ExecuteInto: // The target is a RECORD, which takes on the shape of the query's result columns. @@ -238,6 +249,11 @@ func call(ctx *sql.Context, iFunc InterpretedFunction, stack InterpreterStack) ( if err = stack.UpdateRecord(operation.Target, schema, row); err != nil { return nil, err } + if setsFound(operation) { + if err = stack.SetFound(ctx, len(rows) > 0); err != nil { + return nil, err + } + } case OpCode_DeclareRecord: stack.NewRecord(operation.Target, nil, nil) case OpCode_Get: @@ -245,19 +261,31 @@ func call(ctx *sql.Context, iFunc InterpretedFunction, stack InterpreterStack) ( case OpCode_Goto: // We must compare to the index - 1, so that the increment hits our target if counter <= operation.Index { + // Jumping forward leaves every scope it passes over for good, so each one is torn down + // the same way reaching its ScopeEnd would tear it down. A labelled EXIT out of a nested + // loop passes over that loop's ScopeEnd this way. for ; counter < operation.Index-1; counter++ { switch statements[counter].OpCode { case OpCode_ScopeBegin: stack.PushScope() case OpCode_ScopeEnd: - stack.PopScope() + if err := exitScope(ctx, stack); err != nil { + return nil, err + } } } } else { + // Jumping backward passes a scope's ScopeBegin only when that scope is being left for + // good, so that is torn down like any other scope exit. A labelled CONTINUE of an outer + // loop leaves the inner loop this way. Reaching a ScopeEnd backwards is the opposite: a + // scope that already closed is being re-entered, and the fresh scope this pushes carries + // nothing to tear down when the walk reaches its ScopeBegin a moment later. for ; counter > operation.Index-1; counter-- { switch statements[counter].OpCode { case OpCode_ScopeBegin: - stack.PopScope() + if err := exitScope(ctx, stack); err != nil { + return nil, err + } case OpCode_ScopeEnd: stack.PushScope() } @@ -268,7 +296,13 @@ func call(ctx *sql.Context, iFunc InterpretedFunction, stack InterpreterStack) ( if err != nil { return nil, err } - if retVal.(bool) { + conditionMet := retVal.(bool) + if isLoopCondition(operation) { + // An integer FOR loop has no cursor to carry the fact that its body ran, so its condition + // is what records it, for the FOUND the loop reports once it is left. + stack.MarkScopeLoop(conditionMet) + } + if conditionMet { // We're never changing the scope, so we can just assign it directly. // Also, we must assign to index-1, so that the increment hits our target. counter = operation.Index - 1 @@ -276,10 +310,13 @@ func call(ctx *sql.Context, iFunc InterpretedFunction, stack InterpreterStack) ( case OpCode_InsertInto: // TODO: implement case OpCode_Perform: - _, _, err := iFunc.QueryMultiReturn(ctx, stack, operation.PrimaryData, operation.SecondaryData) + _, rows, err := iFunc.QueryMultiReturn(ctx, stack, operation.PrimaryData, operation.SecondaryData) if err != nil { return nil, err } + if err = stack.SetFound(ctx, queryProducedRow(rows)); err != nil { + return nil, err + } case OpCode_Raise: // TODO: Use the client_min_messages config param to determine which // notice levels to send to the client. @@ -360,13 +397,17 @@ func call(ctx *sql.Context, iFunc InterpretedFunction, stack InterpreterStack) ( return nil, err } stack.InitCursor(operation.Target, schema, rows) + // The loop reports FOUND when it is left even if the query matched nothing. + stack.MarkScopeLoop(false) case OpCode_ForQueryNext: schema, row, ok := stack.AdvanceCursor(operation.PrimaryData) if !ok { - stack.CloseCursor(operation.PrimaryData) - // Jump forward past the loop body and back-goto, same mechanism as OpCode_If. + // Jump forward past the loop body and back-goto, same mechanism as OpCode_If. The loop's + // ScopeEnd is what closes the cursor and reports FOUND, since every way out of the loop + // reaches it and this one does not. counter = operation.Index - 1 } else { + stack.MarkScopeLoop(true) if err := stack.UpdateRecord(operation.Target, schema, row); err != nil { return nil, err } @@ -381,10 +422,15 @@ func call(ctx *sql.Context, iFunc InterpretedFunction, stack InterpreterStack) ( return nil, err } stack.BufferReturnQueryResults(records) + if err = stack.SetFound(ctx, len(rows) > 0); err != nil { + return nil, err + } case OpCode_ScopeBegin: stack.PushScope() case OpCode_ScopeEnd: - stack.PopScope() + if err := exitScope(ctx, stack); err != nil { + return nil, err + } case OpCode_SelectInto: // TODO: implement case OpCode_UpdateInto: @@ -396,6 +442,49 @@ func call(ctx *sql.Context, iFunc InterpretedFunction, stack InterpreterStack) ( return nil, nil } +// exitScope performs everything that leaving a scope entails. Both the ScopeEnd opcode and the forward walk +// of a Goto leave scopes, so they share this rather than each handling scope depth on its own, which would +// leave whichever of them grew a new responsibility last out of step with the other. +// +// A FOR..IN..SELECT loop's scope owns the cursor the loop iterates, so leaving the scope is what closes it. +// Leaving it is also what reports a FOR loop's FOUND: Postgres sets FOUND when such a loop exits, by +// whichever path, to whether the body ran at all, and leaves it alone while the loop is running. A WHILE or +// plain LOOP does not set FOUND, so only a scope its loop marked reports one. +func exitScope(ctx *sql.Context, stack InterpreterStack) error { + if cursorName := stack.ScopeCursor(); len(cursorName) > 0 { + stack.CloseCursor(cursorName) + } + if reportsFound, iterated := stack.ScopeLoop(); reportsFound { + if err := stack.SetFound(ctx, iterated); err != nil { + return err + } + } + stack.PopScope() + return nil +} + +// setsFound reports whether the operation should update the built-in FOUND variable. Only the opcodes that +// static and dynamic execution share have to ask: PostgreSQL defines a static statement as setting FOUND +// and a dynamic EXECUTE as leaving it alone. +func setsFound(operation InterpreterOperation) bool { + return operation.Options[OptionSetsFound] == "true" +} + +// isLoopCondition reports whether the operation is the conditional jump that advances an integer FOR loop. +func isLoopCondition(operation InterpreterOperation) bool { + return operation.Options[OptionLoopCondition] == "true" +} + +// queryProducedRow reports whether a statement produced a row for the purposes of FOUND. A data-modifying +// statement without RETURNING reports a single OkResult row no matter how many rows it touched, so for those +// it is the affected-row count that answers the question. +func queryProducedRow(rows []sql.Row) bool { + if len(rows) == 1 && gmstypes.IsOkResult(rows[0]) { + return rows[0][0].(gmstypes.OkResult).RowsAffected > 0 + } + return len(rows) > 0 +} + // convertRowsToRecords iterates overs |rows| and converts each field in each row // into a RecordValue. |schema| is specified for type information. func convertRowsToRecords(schema sql.Schema, rows []sql.Row) ([][]pgtypes.RecordValue, error) { diff --git a/server/plpgsql/interpreter_operation.go b/server/plpgsql/interpreter_operation.go index a7755e5214..61a87c3f72 100644 --- a/server/plpgsql/interpreter_operation.go +++ b/server/plpgsql/interpreter_operation.go @@ -48,6 +48,17 @@ const ( // Function OpCodes are persisted to disk, so these values MUST be stable across Doltgres versions. ) +// OptionSetsFound is an Options key marking an operation that updates the built-in FOUND variable. Static +// and dynamic execution share an opcode, and PostgreSQL defines a static statement as setting FOUND while a +// dynamic EXECUTE deliberately leaves it alone, so the two are told apart by this option rather than by +// their opcode. Operations that always set FOUND do not carry it. +const OptionSetsFound = "sets_found" + +// OptionLoopCondition is an Options key marking the conditional jump that advances an integer FOR loop. +// Every kind of loop compiles to the same conditional jump, and PostgreSQL defines a FOR loop as setting +// FOUND on exit while a WHILE or a plain LOOP leaves it alone, so the two are told apart by this option. +const OptionLoopCondition = "loop_condition" + // InterpreterOperation is an operation that will be performed by the interpreter. type InterpreterOperation struct { OpCode OpCode diff --git a/server/plpgsql/interpreter_stack.go b/server/plpgsql/interpreter_stack.go index 39b9d4286d..1f1049c11d 100644 --- a/server/plpgsql/interpreter_stack.go +++ b/server/plpgsql/interpreter_stack.go @@ -100,6 +100,11 @@ const ( TriggerOldRecordName = "old" ) +// FoundVariableName is the name of the built-in FOUND variable, which PL/pgSQL creates for every function +// and which reports whether the most recent statement that is defined to set it produced any row. +// https://www.postgresql.org/docs/15/plpgsql-statements.html#PLPGSQL-STATEMENTS-DIAGNOSTICS +const FoundVariableName = "found" + // cursorState holds the result set for a FOR record IN query LOOP cursor. type cursorState struct { Schema sql.Schema @@ -132,6 +137,14 @@ type InterpreterVariableReference struct { type InterpreterScopeDetails struct { variables map[string]*interpreterVariable label string + // cursor names the FOR..IN..SELECT cursor this scope owns, if it is such a loop's scope. The scope + // owning it is what lets the cursor be torn down wherever the loop is left, rather than only where + // the cursor runs out. + cursor string + // reportsFound marks the scope of a loop that sets FOUND when it is left, and iterated records + // whether that loop ever advanced into its body. + reportsFound bool + iterated bool } // InterpreterStack represents the working information that an interpreter will use during execution. It is not exactly @@ -407,13 +420,21 @@ func (is *InterpreterStack) ReturnOutParamResults() any { return record } -// InitCursor stores the result set for a FOR record IN query LOOP cursor. +// InitCursor stores the result set for a FOR record IN query LOOP cursor. The cursor is opened in the +// loop's own scope, which takes ownership of it. func (is *InterpreterStack) InitCursor(name string, schema sql.Schema, rows []sql.Row) { is.cursors[name] = &cursorState{ Schema: schema, Rows: rows, Index: 0, } + is.stack.Peek().cursor = name +} + +// ScopeCursor returns the name of the cursor the current scope owns, or an empty string when the scope is +// not that of a FOR..IN..SELECT loop. +func (is *InterpreterStack) ScopeCursor() string { + return is.stack.Peek().cursor } // AdvanceCursor returns the next row for the named cursor and advances its index. @@ -433,6 +454,32 @@ func (is *InterpreterStack) CloseCursor(name string) { delete(is.cursors, name) } +// MarkScopeLoop marks the current scope as a loop's, whose exit reports FOUND, and records whether the loop +// has just advanced into its body. Only the loop itself knows that it advanced, and it cannot record it in +// FOUND, which PostgreSQL leaves alone until the loop is left. +func (is *InterpreterStack) MarkScopeLoop(advanced bool) { + details := is.stack.Peek() + details.reportsFound = true + details.iterated = details.iterated || advanced +} + +// ScopeLoop reports whether leaving the current scope sets FOUND, and if so whether its loop body ran. +func (is *InterpreterStack) ScopeLoop() (reportsFound bool, iterated bool) { + details := is.stack.Peek() + return details.reportsFound, details.iterated +} + +// SetFound updates the built-in FOUND variable. Functions compiled before FOUND was supported do not declare +// it, and a function may shadow the name with a variable of its own, so anything other than the built-in +// boolean is left alone rather than being overwritten. +func (is *InterpreterStack) SetFound(ctx *sql.Context, found bool) error { + ref := is.GetVariable(FoundVariableName) + if ref.Type == nil || ref.Type.ID != pgtypes.Bool.ID { + return nil + } + return is.SetVariable(ctx, FoundVariableName, found) +} + // UpdateRecord finds the named variable and sets its schema and row value. A nil |val| gives the record the // shape of |schema| with every field NULL, which is what PostgreSQL does when an INTO query matches no rows. func (is *InterpreterStack) UpdateRecord(name string, schema sql.Schema, val sql.Row) error { diff --git a/server/plpgsql/json.go b/server/plpgsql/json.go index 4e978919a4..34f30c0c6a 100644 --- a/server/plpgsql/json.go +++ b/server/plpgsql/json.go @@ -469,6 +469,10 @@ func (stmt *plpgSQL_stmt_execsql) Convert() (ExecuteSQL, error) { Statement: stmt.SQLStmt.Expr.Query, Target: target, TargetIsRecord: targetIsRecord, + // An INTO clause always reports whether it found a row. Without one, only a data-modifying + // statement touches FOUND. The INTO check comes first because the raw text of a SELECT INTO + // still carries the INTO clause, which parses as something else entirely. + SetsFound: stmt.Into || isDataModifying(stmt.SQLStmt.Expr.Query), }, nil } @@ -604,8 +608,9 @@ func (stmt *plpgSQL_stmt_fori) Convert() (block Block, err error) { Expression: incrExpr, }, If{ - Condition: condition, - GotoOffset: 2, + Condition: condition, + GotoOffset: 2, + IsLoopCondition: true, }, Goto{ Offset: 2 + bodySize, diff --git a/server/plpgsql/json_convert.go b/server/plpgsql/json_convert.go index 06c37d891f..4d84a9e5ee 100644 --- a/server/plpgsql/json_convert.go +++ b/server/plpgsql/json_convert.go @@ -38,6 +38,11 @@ func jsonConvert(jsonBlock plpgSQL_block) (Block, error) { } } offset := int32(0) - lowestRecordNumber + // PL/pgSQL creates the built-in FOUND variable itself, immediately after the function's parameters, so + // it arrives looking like a parameter (no line number). We track where it lands so it can be turned + // back into a declaration below. A parameter may also be named `found`, in which case the built-in is + // the later of the two and shadows it, so the last match is the one that counts. + foundVariableIndex := -1 // Then we do a second loop that actually adds all of the datums to the block for _, v := range jsonBlock.Datums { switch { @@ -62,6 +67,9 @@ func jsonConvert(jsonBlock plpgSQL_block) (Block, error) { block.Records[recordParentNumber].Fields, v.RecordField.FieldName) case v.Row != nil: case v.Variable != nil: + if v.Variable.LineNumber == 0 && strings.EqualFold(v.Variable.RefName, FoundVariableName) { + foundVariableIndex = len(block.Variables) + } block.Variables = append(block.Variables, Variable{ Name: v.Variable.RefName, Type: strings.ToLower(v.Variable.Type.Type.Name), @@ -74,6 +82,18 @@ func jsonConvert(jsonBlock plpgSQL_block) (Block, error) { return Block{}, errors.New("unhandled declared variable: expected a record, record field, row, or variable") } } + // FOUND is not passed in by the caller, so it has to be declared and initialized like any other local. + // Postgres starts it out false. + if foundVariableIndex >= 0 { + block.Variables[foundVariableIndex].IsParameter = false + block.Variables[foundVariableIndex].Default = "false" + // The datum names the type as `pg_catalog."boolean"`, which OpCode_Declare cannot resolve: it maps + // a qualified pg_catalog name through TypeForNonKeywordTypeName, which has no entry for the + // `boolean` spelling, so the lookup is left asking for a type named `boolean` and fails. Name the + // type by its canonical name instead. The same gap makes a hand-written `DECLARE b + // pg_catalog.boolean` fail, so teaching that alias table about `boolean` would let this go away. + block.Variables[foundVariableIndex].Type = "pg_catalog.bool" + } // The NEW and OLD records of a trigger appear in the datum list like any other record, but they are // supplied by the trigger invocation rather than declared by the function, so they must not be // redeclared (which would shadow the supplied values with an empty record). diff --git a/server/plpgsql/statements.go b/server/plpgsql/statements.go index 39ef1756a5..77d6428601 100644 --- a/server/plpgsql/statements.go +++ b/server/plpgsql/statements.go @@ -176,6 +176,29 @@ type ExecuteSQL struct { // TargetIsRecord states that Target names a single RECORD variable that receives the entire result row, // rather than a comma-separated list of scalar variables that each receive one column. TargetIsRecord bool + // SetsFound states that the statement updates the built-in FOUND variable. PostgreSQL limits that to + // statements carrying an INTO clause and to data-modifying statements. Everything else leaves FOUND + // as it was, a utility statement such as CREATE TABLE in particular. + SetsFound bool +} + +// isDataModifying reports whether |query| is an INSERT, UPDATE, DELETE, or MERGE. Those are the statements +// PostgreSQL treats as data-modifying when deciding whether to update FOUND; its own compiler records the +// same thing as PLpgSQL_stmt_execsql.mod_stmt. A query that does not parse is reported as not data-modifying, +// since leaving FOUND alone is what every statement outside this set does. +func isDataModifying(query string) bool { + result, err := pg_query.Parse(query) + if err != nil { + return false + } + for _, rawStmt := range result.GetStmts() { + switch rawStmt.GetStmt().GetNode().(type) { + case *pg_query.Node_InsertStmt, *pg_query.Node_UpdateStmt, + *pg_query.Node_DeleteStmt, *pg_query.Node_MergeStmt: + return true + } + } + return false } var _ Statement = ExecuteSQL{} @@ -191,12 +214,16 @@ func (stmt ExecuteSQL) AppendOperations(ops *[]InterpreterOperation, stack *Inte if err != nil { return err } - *ops = append(*ops, InterpreterOperation{ + op := InterpreterOperation{ OpCode: executeOpCode(stmt.TargetIsRecord), PrimaryData: statementStr, SecondaryData: referencedVariables, Target: stmt.Target, - }) + } + if stmt.SetsFound { + op.Options = map[string]string{OptionSetsFound: "true"} + } + *ops = append(*ops, op) return nil } @@ -341,6 +368,9 @@ func (stmt Goto) AppendOperations(ops *[]InterpreterOperation, stack *Interprete type If struct { Condition string GotoOffset int32 + // IsLoopCondition marks this as the conditional jump that advances an integer FOR loop, whose result + // is the only record of the loop having run its body. + IsLoopCondition bool } var _ Statement = If{} @@ -357,12 +387,16 @@ func (stmt If) AppendOperations(ops *[]InterpreterOperation, stack *InterpreterS return err } - *ops = append(*ops, InterpreterOperation{ + op := InterpreterOperation{ OpCode: OpCode_If, PrimaryData: "SELECT " + condition + ";", SecondaryData: referencedVariables, Index: len(*ops) + int(stmt.GotoOffset), - }) + } + if stmt.IsLoopCondition { + op.Options = map[string]string{OptionLoopCondition: "true"} + } + *ops = append(*ops, op) return nil } diff --git a/testing/go/plpgsql_found_test.go b/testing/go/plpgsql_found_test.go new file mode 100644 index 0000000000..0ea56bffba --- /dev/null +++ b/testing/go/plpgsql_found_test.go @@ -0,0 +1,761 @@ +// Copyright 2026 Dolthub, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package _go + +import ( + "testing" + + "github.com/dolthub/go-mysql-server/sql" +) + +// TestPlpgsqlFound covers the built-in FOUND variable. The set of statements that update it, and the ones +// that deliberately leave it alone, were verified against PostgreSQL 16. +func TestPlpgsqlFound(t *testing.T) { + RunScripts(t, []ScriptTest{ + { + Name: "FOUND starts out false", + Assertions: []ScriptTestAssertion{ + { + Query: `CREATE FUNCTION f_init() RETURNS boolean LANGUAGE plpgsql AS $$ +BEGIN RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_init();`, + Expected: []sql.Row{{"f"}}, + }, + }, + }, + { + Name: "SELECT INTO sets FOUND", + SetUpScript: []string{ + `CREATE TABLE k (id int, nm text);`, + `INSERT INTO k VALUES (1, 'a'), (2, 'b');`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `CREATE FUNCTION f_hit() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE v int; +BEGIN SELECT id INTO v FROM k WHERE id = 1; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_hit();`, + Expected: []sql.Row{{"t"}}, + }, + { + Query: `CREATE FUNCTION f_miss() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE v int; +BEGIN SELECT id INTO v FROM k WHERE id = 99; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_miss();`, + Expected: []sql.Row{{"f"}}, + }, + { + // The lowercase spelling resolves to the same variable. + Query: `CREATE FUNCTION f_lower() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE v int; +BEGIN SELECT id INTO v FROM k WHERE id = 99; RETURN found; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_lower();`, + Expected: []sql.Row{{"f"}}, + }, + { + // A RECORD target sets FOUND the same way a scalar one does. + Query: `CREATE FUNCTION f_rec_miss() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE r RECORD; +BEGIN SELECT id INTO r FROM k WHERE id = 99; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_rec_miss();`, + Expected: []sql.Row{{"f"}}, + }, + { + Query: `CREATE FUNCTION f_rec_hit() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE r RECORD; +BEGIN SELECT id INTO r FROM k WHERE id = 1; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_rec_hit();`, + Expected: []sql.Row{{"t"}}, + }, + { + // The real-world shape from the dump: guard the rest of the body on FOUND. + Query: `CREATE FUNCTION f_guard(p int) RETURNS text LANGUAGE plpgsql AS $$ +DECLARE r RECORD; +BEGIN + SELECT id, nm INTO r FROM k WHERE id = p; + IF NOT FOUND THEN RETURN 'absent'; END IF; + RETURN r.nm; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_guard(2);`, + Expected: []sql.Row{{"b"}}, + }, + { + Query: `SELECT f_guard(99);`, + Expected: []sql.Row{{"absent"}}, + }, + }, + }, + { + Name: "statements that leave FOUND alone", + SetUpScript: []string{ + `CREATE TABLE k (id int);`, + `INSERT INTO k VALUES (1);`, + }, + Assertions: []ScriptTestAssertion{ + { + // An assignment is not one of the statements that sets FOUND. + Query: `CREATE FUNCTION f_assign() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE v int; +BEGIN SELECT id INTO v FROM k WHERE id = 1; v := 42; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_assign();`, + Expected: []sql.Row{{"t"}}, + }, + { + // Dynamic EXECUTE deliberately does not set FOUND, even with an INTO clause. + Query: `CREATE FUNCTION f_exec_hit() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE v int; +BEGIN EXECUTE 'SELECT id FROM k WHERE id = 1' INTO v; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_exec_hit();`, + Expected: []sql.Row{{"f"}}, + }, + { + // A utility statement is not data-modifying, so it leaves FOUND as the INTO set it. + Query: `CREATE FUNCTION f_ddl() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE v int; +BEGIN + SELECT id INTO v FROM k WHERE id = 1; + CREATE TABLE f_ddl_scratch (a int); + RETURN FOUND; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_ddl();`, + Expected: []sql.Row{{"t"}}, + }, + { + // The same the other way round: a utility statement cannot turn FOUND on either. + Query: `CREATE FUNCTION f_ddl_miss() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE v int; +BEGIN + SELECT id INTO v FROM k WHERE id = 99; + CREATE TABLE f_ddl_scratch2 (a int); + RETURN FOUND; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_ddl_miss();`, + Expected: []sql.Row{{"f"}}, + }, + { + // A data-modifying statement wrapped in a CTE still counts as data-modifying. + Query: `CREATE FUNCTION f_cte_dml() RETURNS boolean LANGUAGE plpgsql AS $$ +BEGIN + WITH src AS (SELECT 99 AS id) INSERT INTO k SELECT id FROM src; + RETURN FOUND; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_cte_dml();`, + Expected: []sql.Row{{"t"}}, + }, + }, + }, + { + Name: "PERFORM and data-modifying statements set FOUND", + SetUpScript: []string{ + `CREATE TABLE k (id int);`, + `INSERT INTO k VALUES (1), (2), (3);`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `CREATE FUNCTION f_perf_hit() RETURNS boolean LANGUAGE plpgsql AS $$ +BEGIN PERFORM 1 FROM k WHERE id = 1; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_perf_hit();`, + Expected: []sql.Row{{"t"}}, + }, + { + Query: `CREATE FUNCTION f_perf_miss() RETURNS boolean LANGUAGE plpgsql AS $$ +BEGIN PERFORM 1 FROM k WHERE id = 99; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_perf_miss();`, + Expected: []sql.Row{{"f"}}, + }, + { + // DML reports whether it affected any row, not whether it returned one. + Query: `CREATE FUNCTION f_del_miss() RETURNS boolean LANGUAGE plpgsql AS $$ +BEGIN DELETE FROM k WHERE id = 99; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_del_miss();`, + Expected: []sql.Row{{"f"}}, + }, + { + Query: `CREATE FUNCTION f_del_hit() RETURNS boolean LANGUAGE plpgsql AS $$ +BEGIN DELETE FROM k WHERE id = 3; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_del_hit();`, + Expected: []sql.Row{{"t"}}, + }, + { + Query: `CREATE FUNCTION f_upd_miss() RETURNS boolean LANGUAGE plpgsql AS $$ +BEGIN UPDATE k SET id = id WHERE id = 99; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_upd_miss();`, + Expected: []sql.Row{{"f"}}, + }, + { + Query: `CREATE FUNCTION f_ins_plain() RETURNS boolean LANGUAGE plpgsql AS $$ +BEGIN INSERT INTO k VALUES (10); RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_ins_plain();`, + Expected: []sql.Row{{"t"}}, + }, + { + Query: `CREATE FUNCTION f_ins_returning() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE v int; +BEGIN INSERT INTO k VALUES (11) RETURNING id INTO v; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_ins_returning();`, + Expected: []sql.Row{{"t"}}, + }, + }, + }, + { + Name: "FOR..IN..SELECT sets FOUND when the loop exits", + SetUpScript: []string{ + `CREATE TABLE k (id int);`, + `INSERT INTO k VALUES (1), (2);`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `CREATE FUNCTION f_for_hit() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE r RECORD; +BEGIN FOR r IN SELECT id FROM k LOOP END LOOP; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_for_hit();`, + Expected: []sql.Row{{"t"}}, + }, + { + Query: `CREATE FUNCTION f_for_miss() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE r RECORD; +BEGIN FOR r IN SELECT id FROM k WHERE id = 99 LOOP END LOOP; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_for_miss();`, + Expected: []sql.Row{{"f"}}, + }, + { + // The loop does not touch FOUND while it is running, only when it exits. + Query: `CREATE FUNCTION f_in_loop() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE r RECORD; res boolean; +BEGIN FOR r IN SELECT id FROM k LOOP res := FOUND; END LOOP; RETURN res; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_in_loop();`, + Expected: []sql.Row{{"f"}}, + }, + { + // Leaving through EXIT is still leaving, so the loop reports that it ran. + Query: `CREATE FUNCTION f_for_exit() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE r RECORD; n int := 0; +BEGIN + FOR r IN SELECT id FROM k ORDER BY id LOOP n := n + 1; EXIT; END LOOP; + RETURN FOUND; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_for_exit();`, + Expected: []sql.Row{{"t"}}, + }, + { + // EXIT WHEN takes the same path out. + Query: `CREATE FUNCTION f_for_exit_when() RETURNS int LANGUAGE plpgsql AS $$ +DECLARE r RECORD; n int := 0; +BEGIN + FOR r IN SELECT id FROM k ORDER BY id LOOP + n := n + 1; + EXIT WHEN r.id = 1; + END LOOP; + IF NOT FOUND THEN RETURN -1; END IF; + RETURN n; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_for_exit_when();`, + Expected: []sql.Row{{1}}, + }, + { + // A labelled EXIT from inside the inner loop jumps over the inner loop's ScopeEnd on + // its way to the outer one's, so the inner loop is left by the Goto rather than by + // reaching its own ScopeEnd. + Query: `CREATE FUNCTION f_labelled_exit() RETURNS int LANGUAGE plpgsql AS $$ +DECLARE r RECORD; s RECORD; n int := 0; +BEGIN + <> + FOR r IN SELECT id FROM k ORDER BY id LOOP + FOR s IN SELECT id FROM k ORDER BY id LOOP + n := n + 1; + EXIT outer; + END LOOP; + END LOOP; + IF NOT FOUND THEN RETURN -1; END IF; + RETURN n; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_labelled_exit();`, + Expected: []sql.Row{{1}}, + }, + { + // A loop entered twice re-runs its query each time rather than resuming where the + // first pass left the cursor. + Query: `CREATE FUNCTION f_reentered_loop() RETURNS int LANGUAGE plpgsql AS $$ +DECLARE r RECORD; i int; n int := 0; +BEGIN + FOR i IN 1..2 LOOP + FOR r IN SELECT id FROM k ORDER BY id LOOP n := n + 1; END LOOP; + END LOOP; + RETURN n; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_reentered_loop();`, + Expected: []sql.Row{{4}}, + }, + }, + }, + { + Name: "integer FOR loops set FOUND when they exit", + SetUpScript: []string{ + `CREATE TABLE k (id int);`, + `INSERT INTO k VALUES (1);`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `CREATE FUNCTION f_fori_hit() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE i int; +BEGIN FOR i IN 1..3 LOOP END LOOP; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_fori_hit();`, + Expected: []sql.Row{{"t"}}, + }, + { + // A range that is empty never runs the body, so the loop reports that. + Query: `CREATE FUNCTION f_fori_miss() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE i int; +BEGIN FOR i IN 1..0 LOOP END LOOP; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_fori_miss();`, + Expected: []sql.Row{{"f"}}, + }, + { + // An empty range does not leave an earlier statement's FOUND alone, it overwrites it. + Query: `CREATE FUNCTION f_fori_overwrites() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE i int; v int; +BEGIN + SELECT id INTO v FROM k WHERE id = 1; + FOR i IN 1..0 LOOP END LOOP; + RETURN FOUND; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_fori_overwrites();`, + Expected: []sql.Row{{"f"}}, + }, + { + Query: `CREATE FUNCTION f_fori_reverse() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE i int; +BEGIN FOR i IN REVERSE 3..1 LOOP END LOOP; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_fori_reverse();`, + Expected: []sql.Row{{"t"}}, + }, + { + // Leaving through EXIT still reports that the body ran. + Query: `CREATE FUNCTION f_fori_exit() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE i int; n int := 0; +BEGIN FOR i IN 1..3 LOOP n := n + 1; EXIT; END LOOP; RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_fori_exit();`, + Expected: []sql.Row{{"t"}}, + }, + { + // The loop does not touch FOUND while it is running, only when it exits. + Query: `CREATE FUNCTION f_fori_in_loop() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE i int; res boolean; +BEGIN FOR i IN 1..2 LOOP res := FOUND; END LOOP; RETURN res; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_fori_in_loop();`, + Expected: []sql.Row{{"f"}}, + }, + { + // A FOR loop nested in a FOR loop reports its own exit, and the outer one then + // reports over it. + Query: `CREATE FUNCTION f_fori_nested() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE i int; j int; +BEGIN + FOR i IN 1..2 LOOP + FOR j IN 1..0 LOOP END LOOP; + END LOOP; + RETURN FOUND; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_fori_nested();`, + Expected: []sql.Row{{"t"}}, + }, + }, + }, + { + Name: "WHILE and plain LOOP leave FOUND alone", + SetUpScript: []string{ + `CREATE TABLE k (id int);`, + `INSERT INTO k VALUES (1);`, + }, + Assertions: []ScriptTestAssertion{ + { + // A WHILE loop is not a FOR loop, so it does not report anything on exit. + Query: `CREATE FUNCTION f_while_keeps() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE v int; i int := 0; +BEGIN + SELECT id INTO v FROM k WHERE id = 1; + WHILE i < 3 LOOP i := i + 1; END LOOP; + RETURN FOUND; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_while_keeps();`, + Expected: []sql.Row{{"t"}}, + }, + { + // A WHILE whose body never runs cannot turn FOUND on either. + Query: `CREATE FUNCTION f_while_no_body() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE v int; +BEGIN + SELECT id INTO v FROM k WHERE id = 99; + WHILE false LOOP NULL; END LOOP; + RETURN FOUND; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_while_no_body();`, + Expected: []sql.Row{{"f"}}, + }, + { + // The same for an unconditional LOOP. + Query: `CREATE FUNCTION f_loop_keeps() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE v int; +BEGIN + SELECT id INTO v FROM k WHERE id = 1; + LOOP EXIT; END LOOP; + RETURN FOUND; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_loop_keeps();`, + Expected: []sql.Row{{"t"}}, + }, + }, + }, + { + Name: "RETURN QUERY sets FOUND", + SetUpScript: []string{ + `CREATE TABLE k (id int);`, + `INSERT INTO k VALUES (1), (2);`, + }, + Assertions: []ScriptTestAssertion{ + { + // FOUND is reported through an error rather than the result set, because returning + // a set of a scalar type from RETURN QUERY is separately broken. + Query: `CREATE FUNCTION f_rq_miss() RETURNS SETOF int LANGUAGE plpgsql AS $$ +BEGIN + RETURN QUERY SELECT id FROM k WHERE id = 99; + IF NOT FOUND THEN RAISE EXCEPTION 'no rows'; END IF; + RAISE EXCEPTION 'had rows'; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM f_rq_miss();`, + ExpectedErr: `no rows`, + }, + { + Query: `CREATE FUNCTION f_rq_hit() RETURNS SETOF int LANGUAGE plpgsql AS $$ +BEGIN + RETURN QUERY SELECT id FROM k ORDER BY id; + IF NOT FOUND THEN RAISE EXCEPTION 'no rows'; END IF; + RAISE EXCEPTION 'had rows'; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT * FROM f_rq_hit();`, + ExpectedErr: `had rows`, + }, + }, + }, + { + // Modelled on reporting_exception_supersession_consistency from the dump that motivated this + // work: a RECORD target, a NOT FOUND guard, then field comparisons against NEW. + Name: "trigger combining a RECORD target with a NOT FOUND guard", + SetUpScript: []string{ + `CREATE TABLE reporting_exception ( + id int PRIMARY KEY, org_id int, register_key text, entry_key text, + version int, superseded_by_id int);`, + `CREATE FUNCTION resc() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE + successor RECORD; +BEGIN + IF NEW."superseded_by_id" IS NULL THEN + RETURN NULL; + END IF; + + SELECT "register_key", "entry_key", "version" + INTO successor + FROM "reporting_exception" + WHERE "id" = NEW."superseded_by_id" + AND "org_id" = NEW."org_id"; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + IF successor."version" <= NEW."version" THEN + RAISE EXCEPTION 'reporting_exception %: successor must carry a HIGHER version (this row is version %, successor is version %)', + NEW."id", NEW."version", successor."version"; + END IF; + + IF successor."register_key" IS DISTINCT FROM NEW."register_key" + OR successor."entry_key" IS DISTINCT FROM NEW."entry_key" THEN + RAISE EXCEPTION 'reporting_exception %: successor must supersede the SAME entry', NEW."id"; + END IF; + + RETURN NULL; +END; $$;`, + `CREATE TRIGGER trg AFTER INSERT ON reporting_exception FOR EACH ROW EXECUTE FUNCTION resc();`, + }, + Assertions: []ScriptTestAssertion{ + { + // No successor pointer at all, so the trigger returns before the query. + Query: `INSERT INTO reporting_exception VALUES (1, 7, 'rk', 'ek', 1, NULL);`, + Expected: []sql.Row{}, + }, + { + // The successor row does not exist: the NOT FOUND guard accepts the insert. + Query: `INSERT INTO reporting_exception VALUES (2, 7, 'rk', 'ek', 2, 999);`, + Expected: []sql.Row{}, + }, + { + Query: `INSERT INTO reporting_exception VALUES (3, 7, 'rk', 'ek', 9, NULL);`, + Expected: []sql.Row{}, + }, + { + // A successor with a higher version and matching keys is accepted. + Query: `INSERT INTO reporting_exception VALUES (4, 7, 'rk', 'ek', 5, 3);`, + Expected: []sql.Row{}, + }, + { + Query: `INSERT INTO reporting_exception VALUES (5, 7, 'rk', 'ek', 50, 3);`, + ExpectedErr: `successor must carry a HIGHER version`, + }, + { + Query: `INSERT INTO reporting_exception VALUES (6, 7, 'other', 'ek', 1, 3);`, + ExpectedErr: `must supersede the SAME entry`, + }, + { + Query: `SELECT id FROM reporting_exception ORDER BY id;`, + Expected: []sql.Row{{1}, {2}, {3}, {4}}, + }, + }, + }, + { + Name: "FOUND survives a nested block, and can be shadowed", + SetUpScript: []string{ + `CREATE TABLE k (id int);`, + `INSERT INTO k VALUES (1);`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `CREATE FUNCTION f_nested() RETURNS boolean LANGUAGE plpgsql AS $$ +DECLARE v int; +BEGIN + BEGIN SELECT id INTO v FROM k WHERE id = 1; END; + RETURN FOUND; +END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_nested();`, + Expected: []sql.Row{{"t"}}, + }, + { + // A variable the user declares as `found` shadows the built-in. + Query: `CREATE FUNCTION f_shadow() RETURNS int LANGUAGE plpgsql AS $$ +DECLARE found int := 5; +BEGIN RETURN found; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_shadow();`, + Expected: []sql.Row{{5}}, + }, + { + // A parameter named `found` is itself shadowed by the built-in, which PL/pgSQL + // creates after the parameters. + Query: `CREATE FUNCTION f_param(found boolean) RETURNS boolean LANGUAGE plpgsql AS $$ +BEGIN RETURN FOUND; END; $$;`, + Expected: []sql.Row{}, + }, + { + Query: `SELECT f_param(true);`, + Expected: []sql.Row{{"f"}}, + }, + }, + }, + { + // A labelled CONTINUE of an outer loop terminates the inner loop, and PostgreSQL treats that + // as the inner loop exiting: it reports FOUND on the way out, like any other way of leaving + // it. The value is observable at the top of the outer loop's next iteration, which is where + // the CONTINUE lands. + Name: "a labelled CONTINUE reports the inner loop's FOUND", + SetUpScript: []string{ + `CREATE TABLE cf (id int);`, + `INSERT INTO cf VALUES (1), (2), (3);`, + }, + Assertions: []ScriptTestAssertion{ + { + Query: `CREATE FUNCTION f_cont_fori() RETURNS text LANGUAGE plpgsql AS $$ +DECLARE i int; j int; acc text := ''; +BEGIN + <> + FOR i IN 1..3 LOOP + acc := acc || CASE WHEN FOUND THEN 't' ELSE 'f' END; + FOR j IN 1..3 LOOP + CONTINUE outer_loop WHEN j = 1; + END LOOP; + END LOOP; + RETURN acc; +END; $$;`, + Expected: []sql.Row{}, + }, + { + // FOUND is false at the top of the first iteration and true afterwards, since by then + // the inner loop has run its body once and been left by the CONTINUE. + Query: `SELECT f_cont_fori();`, + Expected: []sql.Row{{"ftt"}}, + }, + { + Query: `CREATE FUNCTION f_cont_fors() RETURNS text LANGUAGE plpgsql AS $$ +DECLARE i int; r RECORD; acc text := ''; +BEGIN + <> + FOR i IN 1..3 LOOP + acc := acc || CASE WHEN FOUND THEN 't' ELSE 'f' END; + FOR r IN SELECT id FROM cf ORDER BY id LOOP + CONTINUE outer_loop WHEN r.id = 1; + END LOOP; + END LOOP; + RETURN acc; +END; $$;`, + Expected: []sql.Row{}, + }, + { + // The same holds for a FOR..IN..SELECT loop, whose cursor the CONTINUE also closes. + Query: `SELECT f_cont_fors();`, + Expected: []sql.Row{{"ftt"}}, + }, + { + Query: `CREATE FUNCTION f_cont_empty() RETURNS text LANGUAGE plpgsql AS $$ +DECLARE i int; r RECORD; acc text := ''; +BEGIN + <> + FOR i IN 1..3 LOOP + acc := acc || CASE WHEN FOUND THEN 't' ELSE 'f' END; + FOR r IN SELECT id FROM cf WHERE id < 0 LOOP + NULL; + END LOOP; + CONTINUE outer_loop; + END LOOP; + RETURN acc; +END; $$;`, + Expected: []sql.Row{}, + }, + { + // What the inner loop reports is whether its body ran, not that it was left, so an + // inner loop matching nothing leaves FOUND false on every iteration. + Query: `SELECT f_cont_empty();`, + Expected: []sql.Row{{"fff"}}, + }, + }, + }, + }) +}