Skip to content

Commit e1da531

Browse files
committed
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4yURXfrJTANespBca2wDE
1 parent 2e0f745 commit e1da531

22 files changed

Lines changed: 671 additions & 21 deletions

File tree

internal/compiler/expand.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ func (c *Compiler) expandStmt(qc *QueryCatalog, raw *ast.RawStmt, node ast.Node)
8484
return nil, err
8585
}
8686

87+
// Virtual tables for the OLD and NEW aliases available in a RETURNING
88+
// clause (PostgreSQL 18)
89+
rtables, err := c.returningTables(qc, node)
90+
if err != nil {
91+
return nil, err
92+
}
93+
8794
var targets *ast.List
8895
switch n := node.(type) {
8996
case *ast.DeleteStmt:
@@ -131,7 +138,11 @@ func (c *Compiler) expandStmt(qc *QueryCatalog, raw *ast.RawStmt, node ast.Node)
131138
}
132139
}
133140
}
134-
for _, t := range tables {
141+
starTables := tables
142+
if vt := returningTableForScope(tables, rtables, scope); vt != nil {
143+
starTables = []*Table{vt}
144+
}
145+
for _, t := range starTables {
135146
if scope != "" && scope != t.Rel.Name {
136147
continue
137148
}

internal/compiler/output_columns.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ func (c *Compiler) outputColumns(qc *QueryCatalog, node ast.Node) ([]*Column, er
5858
return nil, err
5959
}
6060

61+
// Virtual tables for the OLD and NEW aliases available in a RETURNING
62+
// clause (PostgreSQL 18)
63+
rtables, err := c.returningTables(qc, node)
64+
if err != nil {
65+
return nil, err
66+
}
67+
6168
targets := &ast.List{}
6269
switch n := node.(type) {
6370
case *ast.DeleteStmt:
@@ -235,7 +242,7 @@ func (c *Compiler) outputColumns(qc *QueryCatalog, node ast.Node) ([]*Column, er
235242
continue
236243
}
237244
if ref, ok := arg.(*ast.ColumnRef); ok {
238-
columns, err := outputColumnRefs(res, tables, ref)
245+
columns, err := outputColumnRefs(res, tablesForRef(ref, tables, rtables), ref)
239246
if err != nil {
240247
return nil, err
241248
}
@@ -268,8 +275,12 @@ func (c *Compiler) outputColumns(qc *QueryCatalog, node ast.Node) ([]*Column, er
268275
}
269276

270277
// TODO: This code is copied in func expand()
271-
for _, t := range tables {
272-
scope := astutils.Join(n.Fields, ".")
278+
scope := astutils.Join(n.Fields, ".")
279+
starTables := tables
280+
if vt := returningTableForScope(tables, rtables, scope); vt != nil {
281+
starTables = []*Table{vt}
282+
}
283+
for _, t := range starTables {
273284
if scope != "" && scope != t.Rel.Name {
274285
continue
275286
}
@@ -297,7 +308,7 @@ func (c *Compiler) outputColumns(qc *QueryCatalog, node ast.Node) ([]*Column, er
297308
continue
298309
}
299310

300-
columns, err := outputColumnRefs(res, tables, n)
311+
columns, err := outputColumnRefs(res, tablesForRef(n, tables, rtables), n)
301312
if err != nil {
302313
return nil, err
303314
}

internal/compiler/returning.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package compiler
2+
3+
import (
4+
"github.com/sqlc-dev/sqlc/internal/config"
5+
"github.com/sqlc-dev/sqlc/internal/sql/ast"
6+
)
7+
8+
// returningTables builds virtual tables for the OLD and NEW aliases that
9+
// PostgreSQL 18 makes available in the RETURNING clause of INSERT, UPDATE and
10+
// DELETE statements. Each alias exposes the columns of the statement's target
11+
// table. For INSERT there is usually no old row and for DELETE there is no
12+
// new row, so every column reached through those aliases becomes nullable.
13+
func (c *Compiler) returningTables(qc *QueryCatalog, node ast.Node) ([]*Table, error) {
14+
if c.conf.Engine != config.EnginePostgreSQL {
15+
return nil, nil
16+
}
17+
18+
var rv *ast.RangeVar
19+
var returning *ast.List
20+
oldAlias, newAlias := "old", "new"
21+
var oldNullable, newNullable bool
22+
switch n := node.(type) {
23+
case *ast.DeleteStmt:
24+
rv = firstRangeVar(n.Relations)
25+
returning = n.ReturningList
26+
if n.ReturningOldAlias != "" {
27+
oldAlias = n.ReturningOldAlias
28+
}
29+
if n.ReturningNewAlias != "" {
30+
newAlias = n.ReturningNewAlias
31+
}
32+
// A deleted row has no new value
33+
newNullable = true
34+
case *ast.InsertStmt:
35+
rv = n.Relation
36+
returning = n.ReturningList
37+
if n.ReturningOldAlias != "" {
38+
oldAlias = n.ReturningOldAlias
39+
}
40+
if n.ReturningNewAlias != "" {
41+
newAlias = n.ReturningNewAlias
42+
}
43+
// An inserted row has no old value, except when an ON CONFLICT
44+
// clause updates an existing row instead
45+
oldNullable = true
46+
case *ast.UpdateStmt:
47+
rv = firstRangeVar(n.Relations)
48+
returning = n.ReturningList
49+
if n.ReturningOldAlias != "" {
50+
oldAlias = n.ReturningOldAlias
51+
}
52+
if n.ReturningNewAlias != "" {
53+
newAlias = n.ReturningNewAlias
54+
}
55+
default:
56+
return nil, nil
57+
}
58+
if rv == nil || returning == nil || len(returning.Items) == 0 {
59+
return nil, nil
60+
}
61+
62+
fqn, err := ParseTableName(rv)
63+
if err != nil {
64+
return nil, err
65+
}
66+
67+
build := func(alias string, nullable bool) *Table {
68+
table, err := qc.GetTable(fqn)
69+
if err != nil {
70+
// An unresolvable target table is reported by the regular
71+
// source table lookup, so ignore the error here
72+
return nil
73+
}
74+
table.Rel = &ast.TableName{Name: alias}
75+
if nullable {
76+
for _, col := range table.Columns {
77+
col.NotNull = false
78+
}
79+
}
80+
return table
81+
}
82+
83+
var tables []*Table
84+
if t := build(oldAlias, oldNullable); t != nil {
85+
tables = append(tables, t)
86+
}
87+
if t := build(newAlias, newNullable); t != nil {
88+
tables = append(tables, t)
89+
}
90+
return tables, nil
91+
}
92+
93+
func firstRangeVar(list *ast.List) *ast.RangeVar {
94+
if list == nil {
95+
return nil
96+
}
97+
for _, item := range list.Items {
98+
if rv, ok := item.(*ast.RangeVar); ok && rv != nil {
99+
return rv
100+
}
101+
}
102+
return nil
103+
}
104+
105+
// returningTableForScope returns the OLD or NEW virtual table named by scope.
106+
// A source table with the same name shadows the virtual table, matching
107+
// PostgreSQL, where the implicit aliases are only available when no relation
108+
// in the query is already known under that name.
109+
func returningTableForScope(tables, rtables []*Table, scope string) *Table {
110+
if scope == "" {
111+
return nil
112+
}
113+
for _, t := range tables {
114+
if t.Rel.Name == scope {
115+
return nil
116+
}
117+
}
118+
for _, t := range rtables {
119+
if t.Rel.Name == scope {
120+
return t
121+
}
122+
}
123+
return nil
124+
}
125+
126+
// tablesForRef resolves a column reference against the source tables,
127+
// extended with the OLD or NEW virtual table when the reference is qualified
128+
// with one of their names.
129+
func tablesForRef(ref *ast.ColumnRef, tables, rtables []*Table) []*Table {
130+
parts := stringSlice(ref.Fields)
131+
if len(parts) != 2 {
132+
return tables
133+
}
134+
if vt := returningTableForScope(tables, rtables, parts[0]); vt != nil {
135+
return append(append([]*Table{}, tables...), vt)
136+
}
137+
return tables
138+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"contexts": ["base"]
3+
}

internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/db.go

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/models.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/endtoend/testdata/returning_old_new/postgresql/pgx/v5/go/query.sql.go

Lines changed: 124 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)