Skip to content

Commit 2cbbbc6

Browse files
m-ali-akbayclaude
andcommitted
feat: add sqlc.jsonb_build_object(...) for inline JSON-shaped results
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent e209d86 commit 2cbbbc6

59 files changed

Lines changed: 8924 additions & 2735 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/embedding.md

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,96 @@ type ScoreAndTestsRow struct {
5858
Student Student
5959
TestScore TestScore
6060
}
61-
```
61+
```
62+
63+
#### Nested JSON objects and arrays
64+
65+
`sqlc.jsonb_build_object."Name"(key, value, key, value, ...)` builds a named
66+
Go struct from an inline JSON shape — a typed wrapper around Postgres's
67+
`jsonb_build_object`, so keys must be string literals. This is most useful
68+
for pulling a one-to-many relationship into a single query as a slice
69+
field, instead of running a separate query per parent row. It's **pgx/v5
70+
only**: `sqlc generate` fails with a clear error for any other
71+
`sql_package`.
72+
73+
`"Name"` is required and must be a double-quoted identifier in that
74+
position, not an argument — Postgres parses this as a 3-part qualified
75+
function name (`catalog.schema.name`), and sqlc reads the struct name
76+
directly off of it.
77+
78+
```sql
79+
CREATE TABLE authors (
80+
id bigserial PRIMARY KEY,
81+
name text NOT NULL
82+
);
83+
84+
CREATE TABLE books (
85+
id bigserial PRIMARY KEY,
86+
author_id bigint NOT NULL REFERENCES authors (id),
87+
title text NOT NULL
88+
);
89+
```
90+
91+
```sql
92+
-- name: GetAuthors :many
93+
SELECT
94+
sqlc.embed(authors),
95+
ARRAY(
96+
SELECT sqlc.jsonb_build_object."Book"('id', books.id, 'title', books.title)
97+
FROM books
98+
WHERE books.author_id = authors.id
99+
) AS books
100+
FROM authors;
101+
```
102+
103+
```go
104+
type GetAuthorsRow struct {
105+
Author Author
106+
Books []Book
107+
}
108+
109+
type Book struct {
110+
ID int64 `json:"id"`
111+
Title string `json:"title"`
112+
}
113+
```
114+
115+
No custom `Scan`/`Value` methods, no wrapper type — `Books` is a plain
116+
`[]Book`; pgx v5 decodes the `jsonb[]` column into it directly. A lone
117+
`sqlc.jsonb_build_object."Name"(...)` (not wrapped in `ARRAY(...)`) works
118+
the same way and produces a plain struct field instead of a slice:
119+
120+
```sql
121+
-- name: GetAuthorSummary :one
122+
SELECT sqlc.jsonb_build_object."AuthorSummary"('name', name) AS summary FROM authors LIMIT 1;
123+
```
124+
125+
```go
126+
type GetAuthorSummaryRow struct {
127+
Summary AuthorSummary
128+
}
129+
```
130+
131+
Two queries that use the same explicit name reuse the same Go type, as long
132+
as their shapes match (this works even mixing scalar and `ARRAY(...)` uses
133+
of the same name). A shape mismatch, or a name that collides with an
134+
existing model/enum type, fails generation with an error instead of
135+
emitting Go code that won't compile.
136+
137+
##### Overriding generated names
138+
139+
The struct name and individual field names can be overridden via the
140+
`rename` option:
141+
142+
```json
143+
{
144+
"rename": {
145+
"Book": "BookSummary",
146+
"Book.id": "BookID"
147+
}
148+
}
149+
```
150+
151+
`"Book"` renames the type; `"Book.id"` renames just the `id` field within
152+
it, without affecting other types that also have an `id` key. Use the plain
153+
key (`"id"`) instead to rename that field everywhere.

internal/cmd/shim.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ func pluginQueryColumn(c *compiler.Column) *plugin.Column {
183183
IsNamedParam: c.IsNamedParam,
184184
IsFuncCall: c.IsFuncCall,
185185
IsSqlcSlice: c.IsSqlcSlice,
186+
JsonName: c.JSONName,
186187
}
187188

188189
if c.Type != nil {
@@ -213,6 +214,10 @@ func pluginQueryColumn(c *compiler.Column) *plugin.Column {
213214
}
214215
}
215216

217+
for _, field := range c.JSONFields {
218+
out.JsonFields = append(out.JsonFields, pluginQueryColumn(field))
219+
}
220+
216221
return out
217222
}
218223

internal/codegen/golang/gen.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ func generate(req *plugin.GenerateRequest, options *opts.Options, enums []Enum,
212212
return nil, errors.New(":batch* commands are only supported by pgx")
213213
}
214214

215+
if usesJSON(queries) && tctx.SQLDriver != opts.SQLDriverPGXV5 {
216+
return nil, errors.New("sqlc.jsonb_build_object(...) is only supported by pgx/v5")
217+
}
218+
215219
funcMap := template.FuncMap{
216220
"lowerTitle": sdk.LowerTitle,
217221
"comment": sdk.DoubleSlashComment,
@@ -356,6 +360,15 @@ func usesBatch(queries []Query) bool {
356360
return false
357361
}
358362

363+
func usesJSON(queries []Query) bool {
364+
for _, q := range queries {
365+
if len(q.JSONTypes) > 0 {
366+
return true
367+
}
368+
}
369+
return false
370+
}
371+
359372
func checkNoTimesForMySQLCopyFrom(queries []Query) error {
360373
for _, q := range queries {
361374
if q.Cmd != metadata.CmdCopyFrom {

internal/codegen/golang/imports.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,14 @@ func (i *importer) queryImports(filename string) fileImports {
350350
return true
351351
}
352352
}
353+
// JSONTypes live outside Ret/Arg, so check them separately.
354+
for _, t := range q.JSONTypes {
355+
for _, f := range t.Fields {
356+
if hasPrefixIgnoringSliceAndPointerPrefix(f.Type, name) {
357+
return true
358+
}
359+
}
360+
}
353361
}
354362
return false
355363
})
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package golang
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/sqlc-dev/sqlc/internal/codegen/golang/opts"
7+
"github.com/sqlc-dev/sqlc/internal/plugin"
8+
)
9+
10+
// JSONType is a plain Go struct synthesized from an
11+
// sqlc.jsonb_build_object."Name"(...) call. No Scan/Value methods are
12+
// generated: pgx v5 decodes a literal ARRAY(SELECT jsonb_build_object(...)
13+
// FROM ...) directly into a bare []JSONType, and a scalar
14+
// jsonb_build_object(...) directly into a bare JSONType, via reflection.
15+
type JSONType struct {
16+
Name string
17+
Fields []Field
18+
}
19+
20+
// newGoJSONColumn resolves the Go type for an sqlc.jsonb_build_object."Name"(...)
21+
// column ([]Name for array results, Name otherwise) and collects the
22+
// JSONType struct declaration(s) it needs, including any nested calls.
23+
// col.JsonName is used as-is, letting two queries share one Go type by
24+
// using the same name (see internJSONTypes).
25+
func newGoJSONColumn(req *plugin.GenerateRequest, options *opts.Options, col *plugin.Column, models modelTypeSet, qualifier string) (string, []JSONType) {
26+
structName := StructName(col.JsonName, options)
27+
28+
var fields []Field
29+
var types []JSONType
30+
for _, f := range col.JsonFields {
31+
tags := map[string]string{"json": f.Name}
32+
addExtraGoStructTags(tags, req, options, f)
33+
34+
var fieldType string
35+
if len(f.JsonFields) > 0 {
36+
var nested []JSONType
37+
fieldType, nested = newGoJSONColumn(req, options, f, models, qualifier)
38+
types = append(types, nested...)
39+
} else {
40+
fieldType = qualifyType(goType(req, options, f), models, qualifier)
41+
}
42+
43+
// A namespaced rename ("Name.fieldKey") takes precedence over
44+
// StructName's own global rename lookup, so the same JSON key can
45+
// be renamed differently in different synthesized structs.
46+
fieldName, ok := options.Rename[col.JsonName+"."+f.Name]
47+
if !ok {
48+
fieldName = StructName(f.Name, options)
49+
}
50+
51+
fields = append(fields, Field{
52+
Name: fieldName,
53+
DBName: f.Name,
54+
Type: fieldType,
55+
Tags: tags,
56+
Column: f,
57+
})
58+
}
59+
60+
types = append(types, JSONType{Name: structName, Fields: fields})
61+
62+
if col.IsArray {
63+
return "[]" + structName, types
64+
}
65+
return structName, types
66+
}
67+
68+
// internJSONTypes deduplicates types against seen (a package-wide map keyed
69+
// by name, mutated in place), returning only the ones that still need to be
70+
// emitted. A name already used by a model/enum, or by an earlier JSONType
71+
// with different fields, is an error rather than a silent duplicate
72+
// declaration or a wrong-shape struct. Scalar and array uses of the same
73+
// name are compatible (the struct itself doesn't change; only the
74+
// referencing field gains a [] prefix), so they intern together.
75+
func internJSONTypes(seen map[string]JSONType, models modelTypeSet, types []JSONType) ([]JSONType, error) {
76+
var toEmit []JSONType
77+
for _, t := range types {
78+
if _, ok := models[t.Name]; ok {
79+
return nil, fmt.Errorf("sqlc.jsonb_build_object.%q(...) collides with an existing model or enum type; give it a different name", t.Name)
80+
}
81+
prev, ok := seen[t.Name]
82+
if !ok {
83+
seen[t.Name] = t
84+
toEmit = append(toEmit, t)
85+
continue
86+
}
87+
if !sameJSONShape(prev, t) {
88+
return nil, fmt.Errorf("sqlc.jsonb_build_object.%q(...) is used with conflicting shapes across queries; give one of them a different name", t.Name)
89+
}
90+
}
91+
return toEmit, nil
92+
}
93+
94+
func sameJSONShape(a, b JSONType) bool {
95+
if len(a.Fields) != len(b.Fields) {
96+
return false
97+
}
98+
for i, f := range a.Fields {
99+
g := b.Fields[i]
100+
if f.Name != g.Name || f.Type != g.Type || f.Tag() != g.Tag() {
101+
return false
102+
}
103+
}
104+
return true
105+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package golang
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func field(name, typ string) Field {
9+
return Field{Name: name, Type: typ, Tags: map[string]string{"json": name}}
10+
}
11+
12+
func TestInternJSONTypes(t *testing.T) {
13+
t.Run("first occurrence is emitted", func(t *testing.T) {
14+
seen := map[string]JSONType{}
15+
toEmit, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{
16+
{Name: "Book", Fields: []Field{field("id", "int64")}},
17+
})
18+
if err != nil {
19+
t.Fatalf("unexpected error: %v", err)
20+
}
21+
if len(toEmit) != 1 {
22+
t.Fatalf("expected 1 type to emit, got %d", len(toEmit))
23+
}
24+
})
25+
26+
t.Run("same name and shape is interned, not re-emitted", func(t *testing.T) {
27+
seen := map[string]JSONType{}
28+
book := JSONType{Name: "Book", Fields: []Field{field("id", "int64")}}
29+
if _, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{book}); err != nil {
30+
t.Fatalf("unexpected error: %v", err)
31+
}
32+
toEmit, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{book})
33+
if err != nil {
34+
t.Fatalf("unexpected error: %v", err)
35+
}
36+
if len(toEmit) != 0 {
37+
t.Errorf("expected 0 types to emit on reuse, got %d", len(toEmit))
38+
}
39+
})
40+
41+
t.Run("same name, different fields is a conflict", func(t *testing.T) {
42+
seen := map[string]JSONType{}
43+
if _, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{
44+
{Name: "Book", Fields: []Field{field("id", "int64")}},
45+
}); err != nil {
46+
t.Fatalf("unexpected error: %v", err)
47+
}
48+
_, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{
49+
{Name: "Book", Fields: []Field{field("id", "int64"), field("title", "string")}},
50+
})
51+
if err == nil || !strings.Contains(err.Error(), "conflicting shapes") {
52+
t.Errorf("error = %v, want mention of conflicting shapes", err)
53+
}
54+
})
55+
56+
t.Run("scalar and array uses of the same name intern together", func(t *testing.T) {
57+
// The struct declaration is identical either way; only the
58+
// referencing field gains a [] prefix, so these aren't a conflict.
59+
seen := map[string]JSONType{}
60+
book := JSONType{Name: "Book", Fields: []Field{field("id", "int64")}}
61+
if _, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{book}); err != nil {
62+
t.Fatalf("unexpected error: %v", err)
63+
}
64+
if _, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{book}); err != nil {
65+
t.Errorf("unexpected error reusing the same shape: %v", err)
66+
}
67+
})
68+
69+
t.Run("name collides with an existing model", func(t *testing.T) {
70+
seen := map[string]JSONType{}
71+
models := modelTypeSet{"Book": struct{}{}}
72+
_, err := internJSONTypes(seen, models, []JSONType{
73+
{Name: "Book", Fields: []Field{field("id", "int64")}},
74+
})
75+
if err == nil || !strings.Contains(err.Error(), "collides with an existing model") {
76+
t.Errorf("error = %v, want mention of model collision", err)
77+
}
78+
})
79+
}
80+
81+
func TestSameJSONShape(t *testing.T) {
82+
tests := []struct {
83+
name string
84+
a, b JSONType
85+
want bool
86+
}{
87+
{
88+
name: "identical",
89+
a: JSONType{Fields: []Field{field("x", "int32")}},
90+
b: JSONType{Fields: []Field{field("x", "int32")}},
91+
want: true,
92+
},
93+
{
94+
name: "different field count",
95+
a: JSONType{Fields: []Field{field("x", "int32")}},
96+
b: JSONType{Fields: []Field{field("x", "int32"), field("y", "string")}},
97+
want: false,
98+
},
99+
{
100+
name: "different field type",
101+
a: JSONType{Fields: []Field{field("x", "int32")}},
102+
b: JSONType{Fields: []Field{field("x", "string")}},
103+
want: false,
104+
},
105+
{
106+
name: "different field name",
107+
a: JSONType{Fields: []Field{field("x", "int32")}},
108+
b: JSONType{Fields: []Field{field("y", "int32")}},
109+
want: false,
110+
},
111+
}
112+
for _, tt := range tests {
113+
t.Run(tt.name, func(t *testing.T) {
114+
if got := sameJSONShape(tt.a, tt.b); got != tt.want {
115+
t.Errorf("sameJSONShape() = %v, want %v", got, tt.want)
116+
}
117+
})
118+
}
119+
}

internal/codegen/golang/query.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,9 @@ type Query struct {
279279
Arg QueryValue
280280
// Used for :copyfrom
281281
Table *plugin.Identifier
282+
// Go types synthesized from sqlc.jsonb_build_object(...) calls used by this query,
283+
// emitted alongside the query's Arg/Ret structs.
284+
JSONTypes []JSONType
282285
}
283286

284287
func (q Query) hasRetType() bool {

0 commit comments

Comments
 (0)