Skip to content

Commit a10ebde

Browse files
committed
feat(postgresql): add MERGE support
1 parent e209d86 commit a10ebde

70 files changed

Lines changed: 2778 additions & 133 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/howto/merge.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Merging rows
2+
3+
sqlc supports PostgreSQL's [`MERGE`](https://www.postgresql.org/docs/current/sql-merge.html)
4+
statement (PostgreSQL 15+). Parameters are inferred in every clause: the source
5+
subquery, the join condition, `WHEN` conditions, `UPDATE SET` assignments, and
6+
`INSERT ... VALUES` lists.
7+
8+
```sql
9+
CREATE TABLE inventory (
10+
sku text PRIMARY KEY,
11+
quantity integer NOT NULL,
12+
updated_by text
13+
);
14+
15+
CREATE TABLE inventory_updates (
16+
sku text PRIMARY KEY,
17+
delta integer NOT NULL
18+
);
19+
```
20+
21+
```sql
22+
-- name: SyncInventory :exec
23+
MERGE INTO inventory AS t
24+
USING inventory_updates AS u
25+
ON t.sku = u.sku
26+
WHEN MATCHED AND t.quantity + u.delta <= $1 THEN
27+
DELETE
28+
WHEN MATCHED THEN
29+
UPDATE SET quantity = t.quantity + u.delta, updated_by = $2
30+
WHEN NOT MATCHED THEN
31+
INSERT (sku, quantity, updated_by) VALUES (u.sku, u.delta, $2);
32+
```
33+
34+
```go
35+
type SyncInventoryParams struct {
36+
Quantity int32
37+
UpdatedBy sql.NullString
38+
}
39+
40+
func (q *Queries) SyncInventory(ctx context.Context, arg SyncInventoryParams) error {
41+
_, err := q.db.ExecContext(ctx, syncInventory, arg.Quantity, arg.UpdatedBy)
42+
return err
43+
}
44+
```
45+
46+
## Returning merged rows
47+
48+
On PostgreSQL 17 and later, `MERGE` supports a `RETURNING` clause, which can be
49+
used with `:one` or `:many`. The clause can reference columns from both the
50+
target and the source relations.
51+
52+
```sql
53+
-- name: MergeInventory :many
54+
MERGE INTO inventory AS t
55+
USING inventory_updates AS u
56+
ON t.sku = u.sku
57+
WHEN MATCHED THEN
58+
UPDATE SET quantity = u.delta
59+
WHEN NOT MATCHED THEN
60+
INSERT (sku, quantity) VALUES (u.sku, u.delta)
61+
RETURNING t.sku, t.quantity;
62+
```
63+
64+
```go
65+
type MergeInventoryRow struct {
66+
Sku string
67+
Quantity int32
68+
}
69+
70+
func (q *Queries) MergeInventory(ctx context.Context) ([]MergeInventoryRow, error) {
71+
// ...
72+
}
73+
```
74+
75+
Note that sqlc does not validate the server version: generating code for a
76+
`MERGE ... RETURNING` query succeeds, but running it against a server older
77+
than PostgreSQL 17 returns a syntax error at execution time.

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ code ever again.
5858
howto/insert.md
5959
howto/update.md
6060
howto/delete.md
61+
howto/merge.md
6162

6263
howto/prepared_query.md
6364
howto/transactions.md

internal/compiler/expand.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func (c *Compiler) expand(qc *QueryCatalog, raw *ast.RawStmt) ([]source.Edit, er
2424
switch node.(type) {
2525
case *ast.DeleteStmt:
2626
case *ast.InsertStmt:
27+
case *ast.MergeStmt:
2728
case *ast.SelectStmt:
2829
case *ast.UpdateStmt:
2930
default:
@@ -90,6 +91,8 @@ func (c *Compiler) expandStmt(qc *QueryCatalog, raw *ast.RawStmt, node ast.Node)
9091
targets = n.ReturningList
9192
case *ast.InsertStmt:
9293
targets = n.ReturningList
94+
case *ast.MergeStmt:
95+
targets = n.ReturningList
9396
case *ast.SelectStmt:
9497
targets = n.TargetList
9598
case *ast.UpdateStmt:

internal/compiler/find_params.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@ import (
77
"github.com/sqlc-dev/sqlc/internal/sql/astutils"
88
)
99

10+
// paramRefForAssignment returns the ParamRef assigned to a single UPDATE SET
11+
// target, handling both `col = $1` and the multi-column form
12+
// `(a, b) = ($1, $2)`, where each column is a separate ResTarget carrying a
13+
// MultiAssignRef with its own Colno pointing into a shared RowExpr source.
14+
func paramRefForAssignment(target *ast.ResTarget) (*ast.ParamRef, bool) {
15+
switch val := target.Val.(type) {
16+
case *ast.ParamRef:
17+
return val, true
18+
case *ast.MultiAssignRef:
19+
row, ok := val.Source.(*ast.RowExpr)
20+
if !ok || row.Args == nil {
21+
return nil, false
22+
}
23+
idx := val.Colno - 1
24+
if idx < 0 || idx >= len(row.Args.Items) {
25+
return nil, false
26+
}
27+
if ref, ok := row.Args.Items[idx].(*ast.ParamRef); ok {
28+
return ref, true
29+
}
30+
}
31+
return nil, false
32+
}
33+
1034
func findParameters(root ast.Node) ([]paramRef, []error) {
1135
refs := make([]paramRef, 0)
1236
errors := make([]error, 0)
@@ -110,6 +134,66 @@ func (p paramSearch) Visit(node ast.Node) astutils.Visitor {
110134
}
111135
}
112136

137+
case *ast.MergeStmt:
138+
if n.MergeWhenClauses == nil {
139+
break
140+
}
141+
for _, item := range n.MergeWhenClauses.Items {
142+
clause, ok := item.(*ast.MergeWhenClause)
143+
if !ok {
144+
continue
145+
}
146+
switch clause.CommandType {
147+
case ast.CmdTypeUpdate:
148+
// WHEN MATCHED THEN UPDATE SET col = $1
149+
if clause.TargetList == nil {
150+
continue
151+
}
152+
for _, item := range clause.TargetList.Items {
153+
target, ok := item.(*ast.ResTarget)
154+
if !ok {
155+
continue
156+
}
157+
ref, ok := paramRefForAssignment(target)
158+
if !ok {
159+
continue
160+
}
161+
*p.refs = append(*p.refs, paramRef{parent: target, ref: ref, rv: n.Relation})
162+
p.seen[ref.Location] = struct{}{}
163+
}
164+
case ast.CmdTypeInsert:
165+
// WHEN NOT MATCHED THEN INSERT (a, b) VALUES ($1, $2)
166+
if clause.Values == nil {
167+
continue
168+
}
169+
if clause.TargetList == nil || len(clause.TargetList.Items) == 0 {
170+
// Without an explicit column list sqlc cannot map the
171+
// positional VALUES parameters to target columns.
172+
params := astutils.Search(clause.Values, func(node ast.Node) bool {
173+
_, ok := node.(*ast.ParamRef)
174+
return ok
175+
})
176+
if len(params.Items) > 0 {
177+
*p.errs = append(*p.errs, fmt.Errorf("MERGE INSERT with parameters requires an explicit column list"))
178+
return p
179+
}
180+
continue
181+
}
182+
for i, v := range clause.Values.Items {
183+
ref, ok := v.(*ast.ParamRef)
184+
if !ok {
185+
continue
186+
}
187+
if len(clause.TargetList.Items) <= i {
188+
*p.errs = append(*p.errs, fmt.Errorf("MERGE INSERT has more expressions than target columns"))
189+
return p
190+
}
191+
*p.refs = append(*p.refs, paramRef{parent: clause.TargetList.Items[i], ref: ref, rv: n.Relation})
192+
p.seen[ref.Location] = struct{}{}
193+
}
194+
}
195+
}
196+
113197
case *ast.UpdateStmt:
114198
for _, item := range n.TargetList.Items {
115199
target, ok := item.(*ast.ResTarget)

internal/compiler/output_columns.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ func (c *Compiler) outputColumns(qc *QueryCatalog, node ast.Node) ([]*Column, er
6363
targets = n.ReturningList
6464
case *ast.InsertStmt:
6565
targets = n.ReturningList
66+
case *ast.MergeStmt:
67+
targets = n.ReturningList
6668
case *ast.SelectStmt:
6769
targets = n.TargetList
6870
isUnion := len(targets.Items) == 0 && n.Larg != nil
@@ -404,9 +406,73 @@ func (c *Compiler) outputColumns(qc *QueryCatalog, node ast.Node) ([]*Column, er
404406
}
405407
}
406408

409+
if m, ok := node.(*ast.MergeStmt); ok && mergeHasNotMatchedBySource(m) {
410+
// A WHEN NOT MATCHED BY SOURCE action returns NULL for every column of
411+
// the source relation (there is no matching source row), so any source
412+
// column in the RETURNING list is nullable. The target is always
413+
// m.Relation, so treat every provenance-bearing column that is not the
414+
// target as a source column (this covers subquery sources too).
415+
for _, col := range cols {
416+
if !col.NotNull || col.skipTableRequiredCheck {
417+
continue
418+
}
419+
// Columns without table provenance (e.g. merge_action()) are never
420+
// NULL from a NOT MATCHED BY SOURCE action.
421+
if col.Table == nil && col.TableAlias == "" {
422+
continue
423+
}
424+
if !columnIsMergeTarget(col, m.Relation) {
425+
col.NotNull = false
426+
}
427+
}
428+
}
429+
407430
return cols, nil
408431
}
409432

433+
// mergeHasNotMatchedBySource reports whether the MERGE has a WHEN NOT MATCHED
434+
// BY SOURCE clause, which makes source-relation columns nullable in RETURNING.
435+
func mergeHasNotMatchedBySource(m *ast.MergeStmt) bool {
436+
if m.MergeWhenClauses == nil {
437+
return false
438+
}
439+
for _, item := range m.MergeWhenClauses.Items {
440+
if clause, ok := item.(*ast.MergeWhenClause); ok {
441+
if clause.MatchKind == ast.MergeWhenNotMatchedBySource {
442+
return true
443+
}
444+
}
445+
}
446+
return false
447+
}
448+
449+
// columnIsMergeTarget reports whether col is qualified as (or resolves to) the
450+
// MERGE target relation.
451+
func columnIsMergeTarget(col *Column, target *ast.RangeVar) bool {
452+
if target == nil {
453+
return false
454+
}
455+
var name, alias string
456+
if target.Relname != nil {
457+
name = *target.Relname
458+
}
459+
if target.Alias != nil && target.Alias.Aliasname != nil {
460+
alias = *target.Alias.Aliasname
461+
}
462+
if col.TableAlias != "" {
463+
// Qualified: it is the target only if the qualifier is the target's
464+
// alias (or, when the target is unaliased, its table name).
465+
if alias != "" {
466+
return col.TableAlias == alias
467+
}
468+
return col.TableAlias == name
469+
}
470+
if col.Table != nil && name != "" {
471+
return col.Table.Name == name
472+
}
473+
return false
474+
}
475+
410476
const (
411477
tableNotFound = iota
412478
tableRequired
@@ -494,6 +560,19 @@ func (c *Compiler) sourceTables(qc *QueryCatalog, node ast.Node) ([]*Table, erro
494560
list = &ast.List{
495561
Items: []ast.Node{n.Relation},
496562
}
563+
case *ast.MergeStmt:
564+
// PostgreSQL expands MERGE ... RETURNING * to all source columns
565+
// followed by all target columns, so collect the source relation
566+
// before the target relation.
567+
// https://www.postgresql.org/docs/17/sql-merge.html
568+
var tv tableVisitor
569+
if n.SourceRelation != nil {
570+
astutils.Walk(&tv, n.SourceRelation)
571+
}
572+
if n.Relation != nil {
573+
astutils.Walk(&tv, n.Relation)
574+
}
575+
list = &tv.list
497576
case *ast.SelectStmt:
498577
var tv tableVisitor
499578
astutils.Walk(&tv, n.FromClause)

internal/compiler/query_catalog.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ func (comp *Compiler) buildQueryCatalog(c *catalog.Catalog, node ast.Node, embed
2121
with = n.WithClause
2222
case *ast.InsertStmt:
2323
with = n.WithClause
24+
case *ast.MergeStmt:
25+
with = n.WithClause
2426
case *ast.UpdateStmt:
2527
with = n.WithClause
2628
case *ast.SelectStmt:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"command": "parse",
3+
"args": ["--dialect", "postgresql", "query.sql"],
4+
"contexts": ["base"]
5+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- name: SyncInventory :exec
2+
MERGE INTO inventory AS t USING inventory_updates AS u ON t.sku = u.sku WHEN MATCHED AND t.quantity <= $1 THEN DELETE WHEN MATCHED THEN UPDATE SET quantity = u.delta WHEN NOT MATCHED THEN INSERT (sku, quantity) VALUES (u.sku, $2);

0 commit comments

Comments
 (0)