From e7f8c21490c71d53ea5a148ce71b01c61a711800 Mon Sep 17 00:00:00 2001 From: SAY-5 Date: Tue, 5 May 2026 03:09:17 -0700 Subject: [PATCH] fix(postgresql): include OUT params when matching CALL signatures Resolves sqlc-dev/sqlc#4216. PostgreSQL CALL requires positional placeholders for OUT parameters, but ResolveFuncCall used InArgs() which strips them, so the supplied arg count never matched the procedure's IN arity and analysis failed with "function ... does not exist". Add Function.CallArgs() and route ast.CallStmt through a new ResolveCallStmt path that matches against IN+OUT arguments. --- .../postgresql/pgx/v5/go/db.go | 32 ++++++++++++++ .../postgresql/pgx/v5/go/models.go | 14 +++++++ .../postgresql/pgx/v5/go/query.sql.go | 42 +++++++++++++++++++ .../postgresql/pgx/v5/query.sql | 8 ++++ .../postgresql/pgx/v5/schema.sql | 11 +++++ .../postgresql/pgx/v5/sqlc.json | 13 ++++++ internal/sql/catalog/func.go | 16 +++++++ internal/sql/catalog/public.go | 21 +++++++++- internal/sql/validate/func_call.go | 24 +++++++++++ 9 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/go/db.go create mode 100644 internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/go/models.go create mode 100644 internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/go/query.sql.go create mode 100644 internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/query.sql create mode 100644 internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/schema.sql create mode 100644 internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/sqlc.json diff --git a/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/go/db.go b/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/go/db.go new file mode 100644 index 0000000000..0057c62319 --- /dev/null +++ b/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/go/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/go/models.go b/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/go/models.go new file mode 100644 index 0000000000..60a7afeb14 --- /dev/null +++ b/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/go/models.go @@ -0,0 +1,14 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type Todo struct { + ID int32 + Task pgtype.Text +} diff --git a/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/go/query.sql.go b/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/go/query.sql.go new file mode 100644 index 0000000000..c430de3522 --- /dev/null +++ b/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/go/query.sql.go @@ -0,0 +1,42 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" +) + +const createTodoNamedOut = `-- name: CreateTodoNamedOut :exec +CALL create_todo($1::text, $2::int) +` + +type CreateTodoNamedOutParams struct { + Task string + ID int32 +} + +func (q *Queries) CreateTodoNamedOut(ctx context.Context, arg CreateTodoNamedOutParams) error { + _, err := q.db.Exec(ctx, createTodoNamedOut, arg.Task, arg.ID) + return err +} + +const createTodoNullPlaceholder = `-- name: CreateTodoNullPlaceholder :exec +CALL create_todo($1::text, null) +` + +func (q *Queries) CreateTodoNullPlaceholder(ctx context.Context, task string) error { + _, err := q.db.Exec(ctx, createTodoNullPlaceholder, task) + return err +} + +const createTodoTypedNullPlaceholder = `-- name: CreateTodoTypedNullPlaceholder :exec +CALL create_todo($1::text, NULL::int) +` + +func (q *Queries) CreateTodoTypedNullPlaceholder(ctx context.Context, task string) error { + _, err := q.db.Exec(ctx, createTodoTypedNullPlaceholder, task) + return err +} diff --git a/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/query.sql b/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/query.sql new file mode 100644 index 0000000000..63cee5be7e --- /dev/null +++ b/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/query.sql @@ -0,0 +1,8 @@ +-- name: CreateTodoNullPlaceholder :exec +CALL create_todo(sqlc.arg(task)::text, null); + +-- name: CreateTodoTypedNullPlaceholder :exec +CALL create_todo(sqlc.arg(task)::text, NULL::int); + +-- name: CreateTodoNamedOut :exec +CALL create_todo(sqlc.arg(task)::text, sqlc.arg(id)::int); diff --git a/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/schema.sql b/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/schema.sql new file mode 100644 index 0000000000..5f9c2b2c0b --- /dev/null +++ b/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/schema.sql @@ -0,0 +1,11 @@ +CREATE TABLE todos ( + id serial PRIMARY KEY, + task text +); + +CREATE PROCEDURE create_todo(IN p_task text, OUT p_id int) +LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO todos (task) VALUES (p_task) RETURNING id INTO p_id; +END; +$$; diff --git a/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/sqlc.json b/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/sqlc.json new file mode 100644 index 0000000000..32ede07158 --- /dev/null +++ b/internal/endtoend/testdata/stored_procedures_pg_out_args/postgresql/pgx/v5/sqlc.json @@ -0,0 +1,13 @@ +{ + "version": "1", + "packages": [ + { + "path": "go", + "engine": "postgresql", + "sql_package": "pgx/v5", + "name": "querytest", + "schema": "schema.sql", + "queries": "query.sql" + } + ] +} diff --git a/internal/sql/catalog/func.go b/internal/sql/catalog/func.go index e170777311..526176826b 100644 --- a/internal/sql/catalog/func.go +++ b/internal/sql/catalog/func.go @@ -39,6 +39,22 @@ func (f *Function) InArgs() []*Argument { return args } +// CallArgs returns the arguments that must be supplied positionally for a +// PostgreSQL CALL statement. Unlike InArgs, OUT parameters are included, since +// CALL requires placeholder values for OUT parameters in their declared +// positions. TABLE parameters remain excluded as they describe return columns +// rather than callable arguments. +func (f *Function) CallArgs() []*Argument { + var args []*Argument + for _, a := range f.Args { + if a.Mode == ast.FuncParamTable { + continue + } + args = append(args, a) + } + return args +} + func (f *Function) OutArgs() []*Argument { var args []*Argument for _, a := range f.Args { diff --git a/internal/sql/catalog/public.go b/internal/sql/catalog/public.go index 19fd50722f..b5abcd9e54 100644 --- a/internal/sql/catalog/public.go +++ b/internal/sql/catalog/public.go @@ -33,6 +33,20 @@ func (c *Catalog) ListFuncsByName(rel *ast.FuncName) ([]Function, error) { } func (c *Catalog) ResolveFuncCall(call *ast.FuncCall) (*Function, error) { + return c.resolveFuncCall(call, false) +} + +// ResolveCallStmt resolves the procedure referenced by a PostgreSQL CALL +// statement. Unlike ResolveFuncCall it includes OUT parameters when matching +// the supplied argument count, because CALL requires placeholder values for +// OUT parameters in their declared positions. +// +// See: https://www.postgresql.org/docs/current/sql-call.html +func (c *Catalog) ResolveCallStmt(call *ast.FuncCall) (*Function, error) { + return c.resolveFuncCall(call, true) +} + +func (c *Catalog) resolveFuncCall(call *ast.FuncCall, isCallStmt bool) (*Function, error) { // Do not validate unknown functions funs, err := c.ListFuncsByName(call.Func) if err != nil || len(funs) == 0 { @@ -64,7 +78,12 @@ func (c *Catalog) ResolveFuncCall(call *ast.FuncCall) (*Function, error) { } for _, fun := range funs { - args := fun.InArgs() + var args []*Argument + if isCallStmt { + args = fun.CallArgs() + } else { + args = fun.InArgs() + } var defaults int var variadic bool known := map[string]struct{}{} diff --git a/internal/sql/validate/func_call.go b/internal/sql/validate/func_call.go index dad621eb12..574f540220 100644 --- a/internal/sql/validate/func_call.go +++ b/internal/sql/validate/func_call.go @@ -21,6 +21,30 @@ func (v *funcCallVisitor) Visit(node ast.Node) astutils.Visitor { return nil } + // PostgreSQL CALL: resolve against all callable parameters (IN + OUT) + // because CALL requires placeholder values for OUT parameters in their + // declared positions. Returning nil here prevents Walk from descending + // into the inner FuncCall, which would otherwise be re-validated using + // the stricter IN-only matching path. + if cs, ok := node.(*ast.CallStmt); ok { + if cs.FuncCall == nil { + return nil + } + fn := cs.FuncCall.Func + if fn == nil || fn.Schema == "sqlc" { + return nil + } + fun, err := v.catalog.ResolveCallStmt(cs.FuncCall) + if fun != nil { + return nil + } + if errors.Is(err, sqlerr.NotFound) && !v.settings.Package.StrictFunctionChecks { + return nil + } + v.err = err + return nil + } + call, ok := node.(*ast.FuncCall) if !ok { return v