Skip to content

Commit 74d6bfc

Browse files
m-ali-akbayclaude
andcommitted
feat: add sqlc.embed.jsonb(table) for whole-row JSON results
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 2cbbbc6 commit 74d6bfc

23 files changed

Lines changed: 415 additions & 11 deletions

File tree

docs/howto/embedding.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,44 @@ The struct name and individual field names can be overridden via the
150150

151151
`"Book"` renames the type; `"Book.id"` renames just the `id` field within
152152
it, without affecting other types that also have an `id` key. Use the plain
153-
key (`"id"`) instead to rename that field everywhere.
153+
key (`"id"`) instead to rename that field everywhere.
154+
155+
##### Embedding a whole row as JSON
156+
157+
Listing every column by hand is tedious when you just want the whole row.
158+
`sqlc.embed.jsonb(table)` builds a JSON object from all of a table's columns,
159+
the same way `sqlc.embed(table)` gives you the table's model — but as a
160+
single JSON value, so it can be nested inside `ARRAY(...)` to return a slice
161+
of rows from one query:
162+
163+
```sql
164+
-- name: GetAuthorsWithBooks :many
165+
SELECT
166+
authors.id,
167+
ARRAY(SELECT sqlc.embed.jsonb(books) FROM books WHERE books.author_id = authors.id) AS books
168+
FROM authors;
169+
```
170+
171+
```go
172+
type GetAuthorsWithBooksRow struct {
173+
ID int64 `json:"id"`
174+
Books []Book `json:"books"`
175+
}
176+
177+
type Book struct {
178+
ID int64 `json:"id"`
179+
AuthorID int64 `json:"author_id"`
180+
Title string `json:"title"`
181+
}
182+
```
183+
184+
The generated struct is named from the result alias — singularized for the
185+
`ARRAY(...)` case (`AS books``Book`), or used as-is for a scalar
186+
`sqlc.embed.jsonb(table) AS author` (→ `Author`). Fields, types and JSON keys
187+
come from the table's columns, so the object always decodes cleanly. Under
188+
the hood the call is rewritten to `to_jsonb(table)`, which you'll see in
189+
`EXPLAIN` output and logs.
190+
191+
Like `sqlc.jsonb_build_object`, this is pgx/v5 only, and the generated name
192+
must not collide with a model or another JSON type; use the `rename` option
193+
if it does.

internal/codegen/golang/gen.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ func generate(req *plugin.GenerateRequest, options *opts.Options, enums []Enum,
213213
}
214214

215215
if usesJSON(queries) && tctx.SQLDriver != opts.SQLDriverPGXV5 {
216-
return nil, errors.New("sqlc.jsonb_build_object(...) is only supported by pgx/v5")
216+
return nil, errors.New("sqlc.jsonb_build_object(...) and sqlc.embed.jsonb(...) are only supported by pgx/v5")
217217
}
218218

219219
funcMap := template.FuncMap{

internal/compiler/json_columns.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55

66
"github.com/sqlc-dev/sqlc/internal/sql/ast"
7+
"github.com/sqlc-dev/sqlc/internal/sql/astutils"
78
)
89

910
// outputJSONColumn resolves the Column produced by an
@@ -59,6 +60,62 @@ func (c *Compiler) outputJSONColumn(qc *QueryCatalog, tables []*Table, res *ast.
5960
}, nil
6061
}
6162

63+
// outputEmbedJSONColumn resolves the Column produced by an
64+
// sqlc.embed.jsonb(table) call: a synthesized struct whose fields mirror the
65+
// referenced table's columns, decoded from a single to_jsonb(table) value
66+
// (see rewrite.IsEmbedJSONCall and expandJSON). The struct name is derived
67+
// from the result alias; for the array case it is left empty here and filled
68+
// in by the enclosing ARRAY_SUBLINK from its own alias.
69+
func (c *Compiler) outputEmbedJSONColumn(qc *QueryCatalog, tables []*Table, res *ast.ResTarget, call *ast.FuncCall) (*Column, error) {
70+
if call.Args == nil || len(call.Args.Items) != 1 {
71+
return nil, fmt.Errorf("sqlc.embed.jsonb(...) takes a single table argument")
72+
}
73+
ref, ok := call.Args.Items[0].(*ast.ColumnRef)
74+
if !ok {
75+
return nil, fmt.Errorf("sqlc.embed.jsonb(...) argument must be a table reference")
76+
}
77+
target := astutils.Join(ref.Fields, ".")
78+
79+
var table *Table
80+
for _, t := range tables {
81+
if t.Rel.Name == target {
82+
table = t
83+
break
84+
}
85+
}
86+
if table == nil {
87+
return nil, fmt.Errorf("sqlc.embed.jsonb(%s): table not found in the query's FROM clause", target)
88+
}
89+
90+
var fields []*Column
91+
for _, col := range table.Columns {
92+
fields = append(fields, &Column{
93+
Name: col.Name,
94+
DataType: col.DataType,
95+
NotNull: col.NotNull,
96+
Unsigned: col.Unsigned,
97+
IsArray: col.IsArray,
98+
ArrayDims: col.ArrayDims,
99+
Length: col.Length,
100+
Type: col.Type,
101+
})
102+
}
103+
104+
name := "json"
105+
jsonName := ""
106+
if res.Name != nil {
107+
name = *res.Name
108+
jsonName = *res.Name
109+
}
110+
return &Column{
111+
Name: name,
112+
DataType: "any",
113+
NotNull: true,
114+
JSONFields: fields,
115+
JSONName: jsonName,
116+
}, nil
117+
}
118+
62119
func jsonStringLiteral(node ast.Node) (string, bool) {
63120
aconst, ok := node.(*ast.A_Const)
64121
if !ok {

internal/compiler/json_columns_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,49 @@ func TestOutputColumnsJSON(t *testing.T) {
153153
}
154154
})
155155
}
156+
157+
func TestOutputColumnsEmbedJSON(t *testing.T) {
158+
c := newTestCompiler(t, testSchema)
159+
160+
t.Run("scalar mirrors the table's columns", func(t *testing.T) {
161+
a := mustAnalyze(t, c, `SELECT sqlc.embed.jsonb(items) AS obj FROM items;`)
162+
col := a.Columns[0]
163+
if col.JSONName != "obj" {
164+
t.Errorf("JSONName = %q, want %q", col.JSONName, "obj")
165+
}
166+
if col.IsArray {
167+
t.Errorf("IsArray = true, want false")
168+
}
169+
if !col.NotNull {
170+
t.Errorf("NotNull = false, want true")
171+
}
172+
if len(col.JSONFields) != 3 {
173+
t.Fatalf("expected 3 JSONFields (id, x, y), got %d", len(col.JSONFields))
174+
}
175+
names := []string{col.JSONFields[0].Name, col.JSONFields[1].Name, col.JSONFields[2].Name}
176+
if names[0] != "id" || names[1] != "x" || names[2] != "y" {
177+
t.Errorf("field names = %v, want [id x y]", names)
178+
}
179+
})
180+
181+
t.Run("array singularizes the outer alias for the element name", func(t *testing.T) {
182+
a := mustAnalyze(t, c, `SELECT ARRAY(SELECT sqlc.embed.jsonb(items) FROM items) AS objs;`)
183+
col := a.Columns[0]
184+
if !col.IsArray || col.ArrayDims != 1 {
185+
t.Errorf("IsArray/ArrayDims = %v/%d, want true/1", col.IsArray, col.ArrayDims)
186+
}
187+
if col.JSONName != "obj" {
188+
t.Errorf("JSONName = %q, want %q (singular of the alias)", col.JSONName, "obj")
189+
}
190+
if len(col.JSONFields) != 3 {
191+
t.Fatalf("expected 3 JSONFields, got %d", len(col.JSONFields))
192+
}
193+
})
194+
195+
t.Run("unknown table", func(t *testing.T) {
196+
err := analyzeErr(t, c, `SELECT sqlc.embed.jsonb(missing) AS obj FROM items;`)
197+
if !strings.Contains(err.Error(), "table not found") {
198+
t.Errorf("error = %v, want mention of table not found", err)
199+
}
200+
})
201+
}

internal/compiler/json_expand.go

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,19 @@ func findOpenParen(s string, from int) (int, error) {
2020
return from + idx, nil
2121
}
2222

23-
// expandJSON rewrites sqlc.jsonb_build_object."Name"(k, v, ...) calls into
24-
// jsonb_build_object(k, v, ...): the qualified name is replaced (the struct
25-
// name has no equivalent in real SQL) but the argument list, already just
26-
// key/value pairs, is left untouched, including any enclosing ARRAY(...).
27-
// pgx v5 decodes a literal ARRAY(SELECT jsonb_build_object(...) FROM ...)
28-
// directly into a Go slice, so no further SQL restructuring is needed.
23+
// expandJSON rewrites sqlc's JSON directives into real Postgres functions,
24+
// replacing only the qualified name span (which has no equivalent in real
25+
// SQL) and leaving the argument list and any enclosing ARRAY(...) untouched:
26+
//
27+
// - sqlc.jsonb_build_object."Name"(k, v, ...) -> jsonb_build_object(k, v, ...)
28+
// - sqlc.embed.jsonb(table) -> to_jsonb(table)
29+
//
30+
// pgx v5 decodes a literal ARRAY(SELECT ... FROM ...) of jsonb directly into
31+
// a Go slice, so no further SQL restructuring is needed.
2932
func (c *Compiler) expandJSON(raw *ast.RawStmt, query string) ([]source.Edit, error) {
30-
calls := astutils.Search(raw, rewrite.IsJSONCall)
33+
calls := astutils.Search(raw, func(node ast.Node) bool {
34+
return rewrite.IsJSONCall(node) || rewrite.IsEmbedJSONCall(node)
35+
})
3136
if len(calls.Items) == 0 {
3237
return nil, nil
3338
}
@@ -43,10 +48,15 @@ func (c *Compiler) expandJSON(raw *ast.RawStmt, query string) ([]source.Edit, er
4348
}
4449
length := openParen - loc
4550

51+
replacement := "jsonb_build_object"
52+
if rewrite.IsEmbedJSONCall(call) {
53+
replacement = "to_jsonb"
54+
}
55+
4656
edits = append(edits, source.Edit{
4757
Location: loc,
4858
OldFunc: func(string) int { return length },
49-
New: "jsonb_build_object",
59+
New: replacement,
5060
})
5161
}
5262

internal/compiler/json_expand_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ func TestExpandJSON(t *testing.T) {
4646
in: `SELECT sqlc.jsonb_build_object.Obj('x', a, 'y', b) AS obj FROM t`,
4747
want: `SELECT jsonb_build_object('x', a, 'y', b) AS obj FROM t`,
4848
},
49+
{
50+
name: "embed.jsonb scalar rewrites to to_jsonb",
51+
in: `SELECT sqlc.embed.jsonb(t) AS obj FROM t`,
52+
want: `SELECT to_jsonb(t) AS obj FROM t`,
53+
},
54+
{
55+
name: "embed.jsonb inside ARRAY(...) leaves the wrapper untouched",
56+
in: `SELECT ARRAY(SELECT sqlc.embed.jsonb(c) FROM c WHERE c.parent_id = p.id) AS children FROM p`,
57+
want: `SELECT ARRAY(SELECT to_jsonb(c) FROM c WHERE c.parent_id = p.id) AS children FROM p`,
58+
},
59+
{
60+
name: "mix of jsonb_build_object and embed.jsonb in one query",
61+
in: `SELECT sqlc.jsonb_build_object."Obj"('x', a) AS obj, sqlc.embed.jsonb(t) AS row FROM t`,
62+
want: `SELECT jsonb_build_object('x', a) AS obj, to_jsonb(t) AS row FROM t`,
63+
},
4964
}
5065

5166
for _, tt := range tests {

internal/compiler/output_columns.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66

7+
"github.com/sqlc-dev/sqlc/internal/inflection"
78
"github.com/sqlc-dev/sqlc/internal/sql/ast"
89
"github.com/sqlc-dev/sqlc/internal/sql/astutils"
910
"github.com/sqlc-dev/sqlc/internal/sql/catalog"
@@ -345,6 +346,14 @@ func (c *Compiler) outputColumn(qc *QueryCatalog, tables []*Table, res *ast.ResT
345346
cols = append(cols, col)
346347
return cols, nil
347348
}
349+
if rewrite.IsEmbedJSONCall(n) {
350+
col, err := c.outputEmbedJSONColumn(qc, tables, res, n)
351+
if err != nil {
352+
return nil, err
353+
}
354+
cols = append(cols, col)
355+
return cols, nil
356+
}
348357

349358
rel := n.Func
350359
name := rel.Name
@@ -397,6 +406,11 @@ func (c *Compiler) outputColumn(qc *QueryCatalog, tables []*Table, res *ast.ResT
397406
if res.Name != nil {
398407
first.Name = *res.Name
399408
}
409+
// An sqlc.embed.jsonb(table) column has no name of its own; the
410+
// array's alias, singularized, names the element struct.
411+
if first.JSONFields != nil && first.JSONName == "" && res.Name != nil {
412+
first.JSONName = inflection.Singular(inflection.SingularParams{Name: *res.Name})
413+
}
400414
if first.IsArray {
401415
first.ArrayDims++
402416
} else {

internal/endtoend/testdata/sqlc_embed_jsonb/postgresql/pgx/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/sqlc_embed_jsonb/postgresql/pgx/go/models.go

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

internal/endtoend/testdata/sqlc_embed_jsonb/postgresql/pgx/go/query.sql.go

Lines changed: 69 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)