Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion docs/howto/embedding.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,44 @@ type ScoreAndTestsRow struct {
Student Student
TestScore TestScore
}
```
```

#### JSON objects and arrays

`sqlc.jsonb_build_object."Name"(key, value, ...)` returns a JSON-shaped
result decoded straight into a named Go struct (or `[]struct` inside
`ARRAY(...)`), useful for pulling a one-to-many relationship into a single
query instead of one query per parent row. It's **pgx/v5 only** — `sqlc
generate` fails with a clear error for any other `sql_package`. `"Name"` is
required and is read off the 3-part qualified function name Postgres parses
(`catalog.schema.name`), not an argument; keys must be string literals.

```sql
-- name: GetAuthors :many
SELECT
authors.id,
ARRAY(
SELECT sqlc.jsonb_build_object."Book"('id', books.id, 'title', books.title)
FROM books WHERE books.author_id = authors.id
) AS books
FROM authors;
```

```go
type GetAuthorsRow struct {
ID int64
Books []Book
}

type Book struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
```

The call is rewritten to Postgres's `jsonb_build_object`, which you'll see in
`EXPLAIN` output. Two queries that use the same name share one Go type when
their shapes match; a shape mismatch, or a name that collides with a model or
another JSON type, fails generation. Names and fields can be overridden with
`rename`: `"Book"` renames the type, `"Book.id"` just the `id` field within
it.
5 changes: 5 additions & 0 deletions internal/cmd/shim.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ func pluginQueryColumn(c *compiler.Column) *plugin.Column {
IsNamedParam: c.IsNamedParam,
IsFuncCall: c.IsFuncCall,
IsSqlcSlice: c.IsSqlcSlice,
JsonName: c.JSONName,
}

if c.Type != nil {
Expand Down Expand Up @@ -213,6 +214,10 @@ func pluginQueryColumn(c *compiler.Column) *plugin.Column {
}
}

for _, field := range c.JSONFields {
out.JsonFields = append(out.JsonFields, pluginQueryColumn(field))
}

return out
}

Expand Down
13 changes: 13 additions & 0 deletions internal/codegen/golang/gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ func generate(req *plugin.GenerateRequest, options *opts.Options, enums []Enum,
return nil, errors.New(":batch* commands are only supported by pgx")
}

if usesJSON(queries) && tctx.SQLDriver != opts.SQLDriverPGXV5 {
return nil, errors.New("sqlc.jsonb_build_object(...) is only supported by pgx/v5")
}

funcMap := template.FuncMap{
"lowerTitle": sdk.LowerTitle,
"comment": sdk.DoubleSlashComment,
Expand Down Expand Up @@ -356,6 +360,15 @@ func usesBatch(queries []Query) bool {
return false
}

func usesJSON(queries []Query) bool {
for _, q := range queries {
if len(q.JSONTypes) > 0 {
return true
}
}
return false
}

func checkNoTimesForMySQLCopyFrom(queries []Query) error {
for _, q := range queries {
if q.Cmd != metadata.CmdCopyFrom {
Expand Down
8 changes: 8 additions & 0 deletions internal/codegen/golang/imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,14 @@ func (i *importer) queryImports(filename string) fileImports {
return true
}
}
// JSONTypes live outside Ret/Arg, so check them separately.
for _, t := range q.JSONTypes {
for _, f := range t.Fields {
if hasPrefixIgnoringSliceAndPointerPrefix(f.Type, name) {
return true
}
}
}
}
return false
})
Expand Down
96 changes: 96 additions & 0 deletions internal/codegen/golang/json_type.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package golang

import (
"fmt"

"github.com/sqlc-dev/sqlc/internal/codegen/golang/opts"
"github.com/sqlc-dev/sqlc/internal/plugin"
)

// JSONType is a plain Go struct synthesized from a JSON directive. No
// Scan/Value methods are needed: pgx v5 decodes the jsonb value directly into
// a bare JSONType (or []JSONType for an array) via reflection.
type JSONType struct {
Name string
Fields []Field
}

// newGoJSONColumn resolves the Go type for a JSON column ([]Name for arrays,
// Name otherwise) and collects the JSONType declarations it needs, including
// nested ones. col.JsonName is used as-is so two queries can share a type.
func newGoJSONColumn(req *plugin.GenerateRequest, options *opts.Options, col *plugin.Column, models modelTypeSet, qualifier string) (string, []JSONType) {
structName := StructName(col.JsonName, options)

var fields []Field
var types []JSONType
for _, f := range col.JsonFields {
tags := map[string]string{"json": f.Name}
addExtraGoStructTags(tags, req, options, f)

var fieldType string
if len(f.JsonFields) > 0 {
var nested []JSONType
fieldType, nested = newGoJSONColumn(req, options, f, models, qualifier)
types = append(types, nested...)
} else {
fieldType = qualifyType(goType(req, options, f), models, qualifier)
}

// A namespaced rename ("Name.fieldKey") wins over the global one.
fieldName, ok := options.Rename[col.JsonName+"."+f.Name]
if !ok {
fieldName = StructName(f.Name, options)
}

fields = append(fields, Field{
Name: fieldName,
DBName: f.Name,
Type: fieldType,
Tags: tags,
Column: f,
})
}

types = append(types, JSONType{Name: structName, Fields: fields})

if col.IsArray {
return "[]" + structName, types
}
return structName, types
}

// internJSONTypes deduplicates types against seen (package-wide, keyed by
// name, mutated in place), returning only those still needing to be emitted.
// A name that collides with a model/enum, or with an earlier type of a
// different shape, is an error. Scalar and array uses share one declaration.
func internJSONTypes(seen map[string]JSONType, models modelTypeSet, types []JSONType) ([]JSONType, error) {
var toEmit []JSONType
for _, t := range types {
if _, ok := models[t.Name]; ok {
return nil, fmt.Errorf("sqlc.jsonb_build_object.%q(...) collides with an existing model or enum type; give it a different name", t.Name)
}
prev, ok := seen[t.Name]
if !ok {
seen[t.Name] = t
toEmit = append(toEmit, t)
continue
}
if !sameJSONShape(prev, t) {
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)
}
}
return toEmit, nil
}

func sameJSONShape(a, b JSONType) bool {
if len(a.Fields) != len(b.Fields) {
return false
}
for i, f := range a.Fields {
g := b.Fields[i]
if f.Name != g.Name || f.Type != g.Type || f.Tag() != g.Tag() {
return false
}
}
return true
}
119 changes: 119 additions & 0 deletions internal/codegen/golang/json_type_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package golang

import (
"strings"
"testing"
)

func field(name, typ string) Field {
return Field{Name: name, Type: typ, Tags: map[string]string{"json": name}}
}

func TestInternJSONTypes(t *testing.T) {
t.Run("first occurrence is emitted", func(t *testing.T) {
seen := map[string]JSONType{}
toEmit, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{
{Name: "Book", Fields: []Field{field("id", "int64")}},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(toEmit) != 1 {
t.Fatalf("expected 1 type to emit, got %d", len(toEmit))
}
})

t.Run("same name and shape is interned, not re-emitted", func(t *testing.T) {
seen := map[string]JSONType{}
book := JSONType{Name: "Book", Fields: []Field{field("id", "int64")}}
if _, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{book}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
toEmit, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{book})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(toEmit) != 0 {
t.Errorf("expected 0 types to emit on reuse, got %d", len(toEmit))
}
})

t.Run("same name, different fields is a conflict", func(t *testing.T) {
seen := map[string]JSONType{}
if _, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{
{Name: "Book", Fields: []Field{field("id", "int64")}},
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{
{Name: "Book", Fields: []Field{field("id", "int64"), field("title", "string")}},
})
if err == nil || !strings.Contains(err.Error(), "conflicting shapes") {
t.Errorf("error = %v, want mention of conflicting shapes", err)
}
})

t.Run("scalar and array uses of the same name intern together", func(t *testing.T) {
// The struct declaration is identical either way; only the
// referencing field gains a [] prefix, so these aren't a conflict.
seen := map[string]JSONType{}
book := JSONType{Name: "Book", Fields: []Field{field("id", "int64")}}
if _, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{book}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, err := internJSONTypes(seen, modelTypeSet{}, []JSONType{book}); err != nil {
t.Errorf("unexpected error reusing the same shape: %v", err)
}
})

t.Run("name collides with an existing model", func(t *testing.T) {
seen := map[string]JSONType{}
models := modelTypeSet{"Book": struct{}{}}
_, err := internJSONTypes(seen, models, []JSONType{
{Name: "Book", Fields: []Field{field("id", "int64")}},
})
if err == nil || !strings.Contains(err.Error(), "collides with an existing model") {
t.Errorf("error = %v, want mention of model collision", err)
}
})
}

func TestSameJSONShape(t *testing.T) {
tests := []struct {
name string
a, b JSONType
want bool
}{
{
name: "identical",
a: JSONType{Fields: []Field{field("x", "int32")}},
b: JSONType{Fields: []Field{field("x", "int32")}},
want: true,
},
{
name: "different field count",
a: JSONType{Fields: []Field{field("x", "int32")}},
b: JSONType{Fields: []Field{field("x", "int32"), field("y", "string")}},
want: false,
},
{
name: "different field type",
a: JSONType{Fields: []Field{field("x", "int32")}},
b: JSONType{Fields: []Field{field("x", "string")}},
want: false,
},
{
name: "different field name",
a: JSONType{Fields: []Field{field("x", "int32")}},
b: JSONType{Fields: []Field{field("y", "int32")}},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sameJSONShape(tt.a, tt.b); got != tt.want {
t.Errorf("sameJSONShape() = %v, want %v", got, tt.want)
}
})
}
}
3 changes: 3 additions & 0 deletions internal/codegen/golang/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@ type Query struct {
Arg QueryValue
// Used for :copyfrom
Table *plugin.Identifier
// Go types synthesized from sqlc.jsonb_build_object(...) calls used by this query,
// emitted alongside the query's Arg/Ret structs.
JSONTypes []JSONType
}

func (q Query) hasRetType() bool {
Expand Down
Loading
Loading