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