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"}}, + }, + }, + }, + }) +}