From e1da53100aed9d28bf5009e0d46280710d69c791 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 20:53:18 +0000 Subject: [PATCH] postgresql: support OLD and NEW in RETURNING clauses PostgreSQL 18 allows the RETURNING clause of INSERT, UPDATE and DELETE statements to reference the row before and after modification through the OLD and NEW aliases, optionally renamed with RETURNING WITH (OLD AS ..., NEW AS ...). sqlc rejected such queries with 'column does not exist' because neither alias resolved to a table. Resolve both aliases to virtual copies of the statement's target table when computing output columns and expanding star references. Columns reached through OLD in an INSERT and through NEW in a DELETE become nullable, since no old or new row exists for those statements. A source table already known under the alias name shadows the virtual table, matching PostgreSQL. The WITH (...) option list is carried through the AST so renamed aliases resolve and format correctly. Fixes #4556 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Q4yURXfrJTANespBca2wDE --- internal/compiler/expand.go | 13 +- internal/compiler/output_columns.go | 19 ++- internal/compiler/returning.go | 138 ++++++++++++++++++ .../postgresql/pgx/v5/exec.json | 3 + .../postgresql/pgx/v5/go/db.go | 32 ++++ .../postgresql/pgx/v5/go/models.go | 15 ++ .../postgresql/pgx/v5/go/query.sql.go | 124 ++++++++++++++++ .../postgresql/pgx/v5/query.sql | 23 +++ .../postgresql/pgx/v5/schema.sql | 5 + .../postgresql/pgx/v5/sqlc.json | 13 ++ .../postgresql/stdlib/exec.json | 3 + .../postgresql/stdlib/go/db.go | 31 ++++ .../postgresql/stdlib/go/models.go | 15 ++ .../postgresql/stdlib/go/query.sql.go | 123 ++++++++++++++++ .../postgresql/stdlib/query.sql | 23 +++ .../postgresql/stdlib/schema.sql | 5 + .../postgresql/stdlib/sqlc.json | 12 ++ internal/engine/postgresql/convert.go | 59 ++++++-- internal/sql/ast/delete_stmt.go | 4 + internal/sql/ast/insert_stmt.go | 4 + internal/sql/ast/returning.go | 24 +++ internal/sql/ast/update_stmt.go | 4 + 22 files changed, 671 insertions(+), 21 deletions(-) create mode 100644 internal/compiler/returning.go create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/exec.json create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/db.go create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/models.go create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/query.sql.go create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/query.sql create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/schema.sql create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/sqlc.json create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/stdlib/exec.json create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/stdlib/go/db.go create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/stdlib/go/models.go create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/stdlib/go/query.sql.go create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/stdlib/query.sql create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/stdlib/schema.sql create mode 100644 internal/endtoend/testdata/returning_old_new/postgresql/stdlib/sqlc.json create mode 100644 internal/sql/ast/returning.go diff --git a/internal/compiler/expand.go b/internal/compiler/expand.go index 98dd82cbdc..c21e413927 100644 --- a/internal/compiler/expand.go +++ b/internal/compiler/expand.go @@ -84,6 +84,13 @@ func (c *Compiler) expandStmt(qc *QueryCatalog, raw *ast.RawStmt, node ast.Node) return nil, err } + // Virtual tables for the OLD and NEW aliases available in a RETURNING + // clause (PostgreSQL 18) + rtables, err := c.returningTables(qc, node) + if err != nil { + return nil, err + } + var targets *ast.List switch n := node.(type) { case *ast.DeleteStmt: @@ -131,7 +138,11 @@ func (c *Compiler) expandStmt(qc *QueryCatalog, raw *ast.RawStmt, node ast.Node) } } } - for _, t := range tables { + starTables := tables + if vt := returningTableForScope(tables, rtables, scope); vt != nil { + starTables = []*Table{vt} + } + for _, t := range starTables { if scope != "" && scope != t.Rel.Name { continue } diff --git a/internal/compiler/output_columns.go b/internal/compiler/output_columns.go index 77c58852cd..76b63eb365 100644 --- a/internal/compiler/output_columns.go +++ b/internal/compiler/output_columns.go @@ -58,6 +58,13 @@ func (c *Compiler) outputColumns(qc *QueryCatalog, node ast.Node) ([]*Column, er return nil, err } + // Virtual tables for the OLD and NEW aliases available in a RETURNING + // clause (PostgreSQL 18) + rtables, err := c.returningTables(qc, node) + if err != nil { + return nil, err + } + targets := &ast.List{} switch n := node.(type) { case *ast.DeleteStmt: @@ -235,7 +242,7 @@ func (c *Compiler) outputColumns(qc *QueryCatalog, node ast.Node) ([]*Column, er continue } if ref, ok := arg.(*ast.ColumnRef); ok { - columns, err := outputColumnRefs(res, tables, ref) + columns, err := outputColumnRefs(res, tablesForRef(ref, tables, rtables), ref) if err != nil { return nil, err } @@ -268,8 +275,12 @@ func (c *Compiler) outputColumns(qc *QueryCatalog, node ast.Node) ([]*Column, er } // TODO: This code is copied in func expand() - for _, t := range tables { - scope := astutils.Join(n.Fields, ".") + scope := astutils.Join(n.Fields, ".") + starTables := tables + if vt := returningTableForScope(tables, rtables, scope); vt != nil { + starTables = []*Table{vt} + } + for _, t := range starTables { if scope != "" && scope != t.Rel.Name { continue } @@ -297,7 +308,7 @@ func (c *Compiler) outputColumns(qc *QueryCatalog, node ast.Node) ([]*Column, er continue } - columns, err := outputColumnRefs(res, tables, n) + columns, err := outputColumnRefs(res, tablesForRef(n, tables, rtables), n) if err != nil { return nil, err } diff --git a/internal/compiler/returning.go b/internal/compiler/returning.go new file mode 100644 index 0000000000..0a38abee86 --- /dev/null +++ b/internal/compiler/returning.go @@ -0,0 +1,138 @@ +package compiler + +import ( + "github.com/sqlc-dev/sqlc/internal/config" + "github.com/sqlc-dev/sqlc/internal/sql/ast" +) + +// returningTables builds virtual tables for the OLD and NEW aliases that +// PostgreSQL 18 makes available in the RETURNING clause of INSERT, UPDATE and +// DELETE statements. Each alias exposes the columns of the statement's target +// table. For INSERT there is usually no old row and for DELETE there is no +// new row, so every column reached through those aliases becomes nullable. +func (c *Compiler) returningTables(qc *QueryCatalog, node ast.Node) ([]*Table, error) { + if c.conf.Engine != config.EnginePostgreSQL { + return nil, nil + } + + var rv *ast.RangeVar + var returning *ast.List + oldAlias, newAlias := "old", "new" + var oldNullable, newNullable bool + switch n := node.(type) { + case *ast.DeleteStmt: + rv = firstRangeVar(n.Relations) + returning = n.ReturningList + if n.ReturningOldAlias != "" { + oldAlias = n.ReturningOldAlias + } + if n.ReturningNewAlias != "" { + newAlias = n.ReturningNewAlias + } + // A deleted row has no new value + newNullable = true + case *ast.InsertStmt: + rv = n.Relation + returning = n.ReturningList + if n.ReturningOldAlias != "" { + oldAlias = n.ReturningOldAlias + } + if n.ReturningNewAlias != "" { + newAlias = n.ReturningNewAlias + } + // An inserted row has no old value, except when an ON CONFLICT + // clause updates an existing row instead + oldNullable = true + case *ast.UpdateStmt: + rv = firstRangeVar(n.Relations) + returning = n.ReturningList + if n.ReturningOldAlias != "" { + oldAlias = n.ReturningOldAlias + } + if n.ReturningNewAlias != "" { + newAlias = n.ReturningNewAlias + } + default: + return nil, nil + } + if rv == nil || returning == nil || len(returning.Items) == 0 { + return nil, nil + } + + fqn, err := ParseTableName(rv) + if err != nil { + return nil, err + } + + build := func(alias string, nullable bool) *Table { + table, err := qc.GetTable(fqn) + if err != nil { + // An unresolvable target table is reported by the regular + // source table lookup, so ignore the error here + return nil + } + table.Rel = &ast.TableName{Name: alias} + if nullable { + for _, col := range table.Columns { + col.NotNull = false + } + } + return table + } + + var tables []*Table + if t := build(oldAlias, oldNullable); t != nil { + tables = append(tables, t) + } + if t := build(newAlias, newNullable); t != nil { + tables = append(tables, t) + } + return tables, nil +} + +func firstRangeVar(list *ast.List) *ast.RangeVar { + if list == nil { + return nil + } + for _, item := range list.Items { + if rv, ok := item.(*ast.RangeVar); ok && rv != nil { + return rv + } + } + return nil +} + +// returningTableForScope returns the OLD or NEW virtual table named by scope. +// A source table with the same name shadows the virtual table, matching +// PostgreSQL, where the implicit aliases are only available when no relation +// in the query is already known under that name. +func returningTableForScope(tables, rtables []*Table, scope string) *Table { + if scope == "" { + return nil + } + for _, t := range tables { + if t.Rel.Name == scope { + return nil + } + } + for _, t := range rtables { + if t.Rel.Name == scope { + return t + } + } + return nil +} + +// tablesForRef resolves a column reference against the source tables, +// extended with the OLD or NEW virtual table when the reference is qualified +// with one of their names. +func tablesForRef(ref *ast.ColumnRef, tables, rtables []*Table) []*Table { + parts := stringSlice(ref.Fields) + if len(parts) != 2 { + return tables + } + if vt := returningTableForScope(tables, rtables, parts[0]); vt != nil { + return append(append([]*Table{}, tables...), vt) + } + return tables +} diff --git a/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/exec.json b/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/exec.json new file mode 100644 index 0000000000..2e996ca79d --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/exec.json @@ -0,0 +1,3 @@ +{ + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/db.go b/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/db.go new file mode 100644 index 0000000000..0057c62319 --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/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/returning_old_new/postgresql/pgx/v5/go/models.go b/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/models.go new file mode 100644 index 0000000000..b2a355d80a --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/models.go @@ -0,0 +1,15 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type User struct { + ID int64 + Name string + Bio pgtype.Text +} diff --git a/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/query.sql.go b/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/query.sql.go new file mode 100644 index 0000000000..1ef5f230f1 --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/query.sql.go @@ -0,0 +1,124 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteReturningOldNew = `-- name: DeleteReturningOldNew :one +DELETE FROM users WHERE id = $1 +RETURNING old.name, new.name +` + +type DeleteReturningOldNewRow struct { + Name string + Name_2 pgtype.Text +} + +func (q *Queries) DeleteReturningOldNew(ctx context.Context, id int64) (DeleteReturningOldNewRow, error) { + row := q.db.QueryRow(ctx, deleteReturningOldNew, id) + var i DeleteReturningOldNewRow + err := row.Scan(&i.Name, &i.Name_2) + return i, err +} + +const insertReturningOldNew = `-- name: InsertReturningOldNew :one +INSERT INTO users (name) VALUES ($1) +RETURNING old.id, new.id +` + +type InsertReturningOldNewRow struct { + ID pgtype.Int8 + ID_2 int64 +} + +func (q *Queries) InsertReturningOldNew(ctx context.Context, name string) (InsertReturningOldNewRow, error) { + row := q.db.QueryRow(ctx, insertReturningOldNew, name) + var i InsertReturningOldNewRow + err := row.Scan(&i.ID, &i.ID_2) + return i, err +} + +const updateReturningOld = `-- name: UpdateReturningOld :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING old.name +` + +type UpdateReturningOldParams struct { + Name string + ID int64 +} + +func (q *Queries) UpdateReturningOld(ctx context.Context, arg UpdateReturningOldParams) (string, error) { + row := q.db.QueryRow(ctx, updateReturningOld, arg.Name, arg.ID) + var name string + err := row.Scan(&name) + return name, err +} + +const updateReturningOldNew = `-- name: UpdateReturningOldNew :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING old.name, new.name +` + +type UpdateReturningOldNewParams struct { + Name string + ID int64 +} + +type UpdateReturningOldNewRow struct { + Name string + Name_2 string +} + +func (q *Queries) UpdateReturningOldNew(ctx context.Context, arg UpdateReturningOldNewParams) (UpdateReturningOldNewRow, error) { + row := q.db.QueryRow(ctx, updateReturningOldNew, arg.Name, arg.ID) + var i UpdateReturningOldNewRow + err := row.Scan(&i.Name, &i.Name_2) + return i, err +} + +const updateReturningOldStar = `-- name: UpdateReturningOldStar :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING old.id, old.name, old.bio +` + +type UpdateReturningOldStarParams struct { + Name string + ID int64 +} + +func (q *Queries) UpdateReturningOldStar(ctx context.Context, arg UpdateReturningOldStarParams) (User, error) { + row := q.db.QueryRow(ctx, updateReturningOldStar, arg.Name, arg.ID) + var i User + err := row.Scan(&i.ID, &i.Name, &i.Bio) + return i, err +} + +const updateReturningWithAliases = `-- name: UpdateReturningWithAliases :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING WITH (OLD AS o, NEW AS n) o.name, n.name +` + +type UpdateReturningWithAliasesParams struct { + Name string + ID int64 +} + +type UpdateReturningWithAliasesRow struct { + Name string + Name_2 string +} + +func (q *Queries) UpdateReturningWithAliases(ctx context.Context, arg UpdateReturningWithAliasesParams) (UpdateReturningWithAliasesRow, error) { + row := q.db.QueryRow(ctx, updateReturningWithAliases, arg.Name, arg.ID) + var i UpdateReturningWithAliasesRow + err := row.Scan(&i.Name, &i.Name_2) + return i, err +} diff --git a/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/query.sql b/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/query.sql new file mode 100644 index 0000000000..ca465ee980 --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/query.sql @@ -0,0 +1,23 @@ +-- name: UpdateReturningOld :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING old.name; + +-- name: UpdateReturningOldNew :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING old.name, new.name; + +-- name: UpdateReturningOldStar :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING old.*; + +-- name: InsertReturningOldNew :one +INSERT INTO users (name) VALUES ($1) +RETURNING old.id, new.id; + +-- name: DeleteReturningOldNew :one +DELETE FROM users WHERE id = $1 +RETURNING old.name, new.name; + +-- name: UpdateReturningWithAliases :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING WITH (OLD AS o, NEW AS n) o.name, n.name; diff --git a/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/schema.sql b/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/schema.sql new file mode 100644 index 0000000000..ad2d63a4f9 --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + id bigserial PRIMARY KEY, + name text NOT NULL, + bio text +); diff --git a/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/sqlc.json b/internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/sqlc.json new file mode 100644 index 0000000000..32ede07158 --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/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/endtoend/testdata/returning_old_new/postgresql/stdlib/exec.json b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/exec.json new file mode 100644 index 0000000000..2e996ca79d --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/exec.json @@ -0,0 +1,3 @@ +{ + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/go/db.go b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/go/db.go new file mode 100644 index 0000000000..80dd6ab1f6 --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/go/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/go/models.go b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/go/models.go new file mode 100644 index 0000000000..b5f44f2b8b --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/go/models.go @@ -0,0 +1,15 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "database/sql" +) + +type User struct { + ID int64 + Name string + Bio sql.NullString +} diff --git a/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/go/query.sql.go b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/go/query.sql.go new file mode 100644 index 0000000000..ba3139ee00 --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/go/query.sql.go @@ -0,0 +1,123 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" + "database/sql" +) + +const deleteReturningOldNew = `-- name: DeleteReturningOldNew :one +DELETE FROM users WHERE id = $1 +RETURNING old.name, new.name +` + +type DeleteReturningOldNewRow struct { + Name string + Name_2 sql.NullString +} + +func (q *Queries) DeleteReturningOldNew(ctx context.Context, id int64) (DeleteReturningOldNewRow, error) { + row := q.db.QueryRowContext(ctx, deleteReturningOldNew, id) + var i DeleteReturningOldNewRow + err := row.Scan(&i.Name, &i.Name_2) + return i, err +} + +const insertReturningOldNew = `-- name: InsertReturningOldNew :one +INSERT INTO users (name) VALUES ($1) +RETURNING old.id, new.id +` + +type InsertReturningOldNewRow struct { + ID sql.NullInt64 + ID_2 int64 +} + +func (q *Queries) InsertReturningOldNew(ctx context.Context, name string) (InsertReturningOldNewRow, error) { + row := q.db.QueryRowContext(ctx, insertReturningOldNew, name) + var i InsertReturningOldNewRow + err := row.Scan(&i.ID, &i.ID_2) + return i, err +} + +const updateReturningOld = `-- name: UpdateReturningOld :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING old.name +` + +type UpdateReturningOldParams struct { + Name string + ID int64 +} + +func (q *Queries) UpdateReturningOld(ctx context.Context, arg UpdateReturningOldParams) (string, error) { + row := q.db.QueryRowContext(ctx, updateReturningOld, arg.Name, arg.ID) + var name string + err := row.Scan(&name) + return name, err +} + +const updateReturningOldNew = `-- name: UpdateReturningOldNew :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING old.name, new.name +` + +type UpdateReturningOldNewParams struct { + Name string + ID int64 +} + +type UpdateReturningOldNewRow struct { + Name string + Name_2 string +} + +func (q *Queries) UpdateReturningOldNew(ctx context.Context, arg UpdateReturningOldNewParams) (UpdateReturningOldNewRow, error) { + row := q.db.QueryRowContext(ctx, updateReturningOldNew, arg.Name, arg.ID) + var i UpdateReturningOldNewRow + err := row.Scan(&i.Name, &i.Name_2) + return i, err +} + +const updateReturningOldStar = `-- name: UpdateReturningOldStar :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING old.id, old.name, old.bio +` + +type UpdateReturningOldStarParams struct { + Name string + ID int64 +} + +func (q *Queries) UpdateReturningOldStar(ctx context.Context, arg UpdateReturningOldStarParams) (User, error) { + row := q.db.QueryRowContext(ctx, updateReturningOldStar, arg.Name, arg.ID) + var i User + err := row.Scan(&i.ID, &i.Name, &i.Bio) + return i, err +} + +const updateReturningWithAliases = `-- name: UpdateReturningWithAliases :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING WITH (OLD AS o, NEW AS n) o.name, n.name +` + +type UpdateReturningWithAliasesParams struct { + Name string + ID int64 +} + +type UpdateReturningWithAliasesRow struct { + Name string + Name_2 string +} + +func (q *Queries) UpdateReturningWithAliases(ctx context.Context, arg UpdateReturningWithAliasesParams) (UpdateReturningWithAliasesRow, error) { + row := q.db.QueryRowContext(ctx, updateReturningWithAliases, arg.Name, arg.ID) + var i UpdateReturningWithAliasesRow + err := row.Scan(&i.Name, &i.Name_2) + return i, err +} diff --git a/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/query.sql b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/query.sql new file mode 100644 index 0000000000..ca465ee980 --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/query.sql @@ -0,0 +1,23 @@ +-- name: UpdateReturningOld :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING old.name; + +-- name: UpdateReturningOldNew :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING old.name, new.name; + +-- name: UpdateReturningOldStar :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING old.*; + +-- name: InsertReturningOldNew :one +INSERT INTO users (name) VALUES ($1) +RETURNING old.id, new.id; + +-- name: DeleteReturningOldNew :one +DELETE FROM users WHERE id = $1 +RETURNING old.name, new.name; + +-- name: UpdateReturningWithAliases :one +UPDATE users SET name = $1 WHERE id = $2 +RETURNING WITH (OLD AS o, NEW AS n) o.name, n.name; diff --git a/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/schema.sql b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/schema.sql new file mode 100644 index 0000000000..ad2d63a4f9 --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + id bigserial PRIMARY KEY, + name text NOT NULL, + bio text +); diff --git a/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/sqlc.json b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/sqlc.json new file mode 100644 index 0000000000..f717ca2e66 --- /dev/null +++ b/internal/endtoend/testdata/returning_old_new/postgresql/stdlib/sqlc.json @@ -0,0 +1,12 @@ +{ + "version": "1", + "packages": [ + { + "path": "go", + "engine": "postgresql", + "name": "querytest", + "schema": "schema.sql", + "queries": "query.sql" + } + ] +} diff --git a/internal/engine/postgresql/convert.go b/internal/engine/postgresql/convert.go index 6a71c609dc..b1f746d193 100644 --- a/internal/engine/postgresql/convert.go +++ b/internal/engine/postgresql/convert.go @@ -1418,15 +1418,36 @@ func convertDeleteStmt(n *pg.DeleteStmt) *ast.DeleteStmt { if n == nil { return nil } + oldAlias, newAlias := convertReturningOptions(n.ReturningClause) return &ast.DeleteStmt{ Relations: &ast.List{ Items: []ast.Node{convertRangeVar(n.Relation)}, }, - UsingClause: convertSlice(n.UsingClause), - WhereClause: convertNode(n.WhereClause), - ReturningList: convertSlice(n.ReturningClause.GetExprs()), - WithClause: convertWithClause(n.WithClause), + UsingClause: convertSlice(n.UsingClause), + WhereClause: convertNode(n.WhereClause), + ReturningList: convertSlice(n.ReturningClause.GetExprs()), + ReturningOldAlias: oldAlias, + ReturningNewAlias: newAlias, + WithClause: convertWithClause(n.WithClause), + } +} + +// convertReturningOptions extracts the aliases assigned to the OLD and NEW +// rows by a PostgreSQL 18 RETURNING WITH (...) option list. +func convertReturningOptions(n *pg.ReturningClause) (oldAlias, newAlias string) { + for _, option := range n.GetOptions() { + ro := option.GetReturningOption() + if ro == nil { + continue + } + switch ro.Option { + case pg.ReturningOptionKind_RETURNING_OPTION_OLD: + oldAlias = ro.Value + case pg.ReturningOptionKind_RETURNING_OPTION_NEW: + newAlias = ro.Value + } } + return oldAlias, newAlias } func convertDiscardStmt(n *pg.DiscardStmt) *ast.DiscardStmt { @@ -1801,14 +1822,17 @@ func convertInsertStmt(n *pg.InsertStmt) *ast.InsertStmt { if n == nil { return nil } + oldAlias, newAlias := convertReturningOptions(n.ReturningClause) return &ast.InsertStmt{ - Relation: convertRangeVar(n.Relation), - Cols: convertSlice(n.Cols), - SelectStmt: convertNode(n.SelectStmt), - OnConflictClause: convertOnConflictClause(n.OnConflictClause), - ReturningList: convertSlice(n.ReturningClause.GetExprs()), - WithClause: convertWithClause(n.WithClause), - Override: ast.OverridingKind(n.Override), + Relation: convertRangeVar(n.Relation), + Cols: convertSlice(n.Cols), + SelectStmt: convertNode(n.SelectStmt), + OnConflictClause: convertOnConflictClause(n.OnConflictClause), + ReturningList: convertSlice(n.ReturningClause.GetExprs()), + ReturningOldAlias: oldAlias, + ReturningNewAlias: newAlias, + WithClause: convertWithClause(n.WithClause), + Override: ast.OverridingKind(n.Override), } } @@ -2805,15 +2829,18 @@ func convertUpdateStmt(n *pg.UpdateStmt) *ast.UpdateStmt { return nil } + oldAlias, newAlias := convertReturningOptions(n.ReturningClause) return &ast.UpdateStmt{ Relations: &ast.List{ Items: []ast.Node{convertRangeVar(n.Relation)}, }, - TargetList: convertSlice(n.TargetList), - WhereClause: convertNode(n.WhereClause), - FromClause: convertSlice(n.FromClause), - ReturningList: convertSlice(n.ReturningClause.GetExprs()), - WithClause: convertWithClause(n.WithClause), + TargetList: convertSlice(n.TargetList), + WhereClause: convertNode(n.WhereClause), + FromClause: convertSlice(n.FromClause), + ReturningList: convertSlice(n.ReturningClause.GetExprs()), + ReturningOldAlias: oldAlias, + ReturningNewAlias: newAlias, + WithClause: convertWithClause(n.WithClause), } } diff --git a/internal/sql/ast/delete_stmt.go b/internal/sql/ast/delete_stmt.go index d23617881a..74683f20ba 100644 --- a/internal/sql/ast/delete_stmt.go +++ b/internal/sql/ast/delete_stmt.go @@ -12,6 +12,9 @@ type DeleteStmt struct { // MySQL multi-table DELETE support Targets *List // Tables to delete from (e.g., jt.*, pt.*) FromClause Node // FROM clause with JOINs (Node to support JoinExpr) + // PostgreSQL 18 RETURNING WITH (OLD AS ..., NEW AS ...) aliases + ReturningOldAlias string + ReturningNewAlias string } func (n *DeleteStmt) Pos() int { @@ -63,6 +66,7 @@ func (n *DeleteStmt) Format(buf *TrackedBuffer, d format.Dialect) { if items(n.ReturningList) { buf.WriteString(" RETURNING ") + formatReturningOptions(buf, d, n.ReturningOldAlias, n.ReturningNewAlias) buf.astFormat(n.ReturningList, d) } } diff --git a/internal/sql/ast/insert_stmt.go b/internal/sql/ast/insert_stmt.go index 4d5c8d1df2..6568deb328 100644 --- a/internal/sql/ast/insert_stmt.go +++ b/internal/sql/ast/insert_stmt.go @@ -12,6 +12,9 @@ type InsertStmt struct { WithClause *WithClause Override OverridingKind DefaultValues bool // SQLite-specific: INSERT INTO ... DEFAULT VALUES + // PostgreSQL 18 RETURNING WITH (OLD AS ..., NEW AS ...) aliases + ReturningOldAlias string + ReturningNewAlias string } func (n *InsertStmt) Pos() int { @@ -57,6 +60,7 @@ func (n *InsertStmt) Format(buf *TrackedBuffer, d format.Dialect) { if items(n.ReturningList) { buf.WriteString(" RETURNING ") + formatReturningOptions(buf, d, n.ReturningOldAlias, n.ReturningNewAlias) buf.astFormat(n.ReturningList, d) } } diff --git a/internal/sql/ast/returning.go b/internal/sql/ast/returning.go new file mode 100644 index 0000000000..70de843415 --- /dev/null +++ b/internal/sql/ast/returning.go @@ -0,0 +1,24 @@ +package ast + +import "github.com/sqlc-dev/sqlc/internal/sql/format" + +// formatReturningOptions writes the PostgreSQL 18 RETURNING WITH (...) option +// list that renames the OLD and NEW aliases available in a RETURNING clause. +func formatReturningOptions(buf *TrackedBuffer, d format.Dialect, oldAlias, newAlias string) { + if oldAlias == "" && newAlias == "" { + return + } + buf.WriteString("WITH (") + if oldAlias != "" { + buf.WriteString("OLD AS ") + buf.WriteString(d.QuoteIdent(oldAlias)) + } + if newAlias != "" { + if oldAlias != "" { + buf.WriteString(", ") + } + buf.WriteString("NEW AS ") + buf.WriteString(d.QuoteIdent(newAlias)) + } + buf.WriteString(") ") +} diff --git a/internal/sql/ast/update_stmt.go b/internal/sql/ast/update_stmt.go index 5376a8c6ce..54382b3f43 100644 --- a/internal/sql/ast/update_stmt.go +++ b/internal/sql/ast/update_stmt.go @@ -14,6 +14,9 @@ type UpdateStmt struct { LimitCount Node ReturningList *List WithClause *WithClause + // PostgreSQL 18 RETURNING WITH (OLD AS ..., NEW AS ...) aliases + ReturningOldAlias string + ReturningNewAlias string } func (n *UpdateStmt) Pos() int { @@ -117,6 +120,7 @@ func (n *UpdateStmt) Format(buf *TrackedBuffer, d format.Dialect) { if items(n.ReturningList) { buf.WriteString(" RETURNING ") + formatReturningOptions(buf, d, n.ReturningOldAlias, n.ReturningNewAlias) buf.astFormat(n.ReturningList, d) } }