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
100 changes: 100 additions & 0 deletions docs/howto/row_types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Sharing a row type between queries

sqlc generates a struct for the rows of each query that returns more than one
column, named after the query. Queries that select the same columns therefore
return different types, even though the structs are identical.

```sql
CREATE TABLE authors (
id BIGSERIAL PRIMARY KEY,
name text NOT NULL
);

CREATE TABLE books (
id BIGSERIAL PRIMARY KEY,
author_id bigint NOT NULL REFERENCES authors (id),
title text NOT NULL
);
```

```sql
-- name: GetBook :one
SELECT books.id, books.title, authors.name AS author_name
FROM books
JOIN authors ON authors.id = books.author_id
WHERE books.id = $1;

-- name: ListBooksByAuthor :many
SELECT books.id, books.title, authors.name AS author_name
FROM books
JOIN authors ON authors.id = books.author_id
WHERE books.author_id = $1;
```

```go
type GetBookRow struct {
ID int64
Title string
AuthorName string
}

type ListBooksByAuthorRow struct {
ID int64
Title string
AuthorName string
}
```

To have them return one type, name it with `:type <TypeName>` after the
command:

```sql
-- name: GetBook :one :type BookWithAuthor
SELECT books.id, books.title, authors.name AS author_name
FROM books
JOIN authors ON authors.id = books.author_id
WHERE books.id = $1;

-- name: ListBooksByAuthor :many :type BookWithAuthor
SELECT books.id, books.title, authors.name AS author_name
FROM books
JOIN authors ON authors.id = books.author_id
WHERE books.author_id = $1;
```

```go
type BookWithAuthor struct {
ID int64
Title string
AuthorName string
}

func (q *Queries) GetBook(ctx context.Context, id int64) (BookWithAuthor, error) {
// ...
}

func (q *Queries) ListBooksByAuthor(ctx context.Context, authorID int64) ([]BookWithAuthor, error) {
// ...
}
```

The type name works with [embedded structs](embedding.md) too, and a single
query can use it just to choose the name of its row type.

## Rules

- Every query that names a type must return the same fields, in the same
order, with the same Go types and struct tags. Otherwise `sqlc generate`
fails and says which column differs:

```
query ListBooks: :type BookWithAuthor does not match query GetBook: column 3 is AuthorID int64, want AuthorName string
```

- The query must return more than one column, since a query with a single
column returns that column's value instead of a struct.
- The name must not be one sqlc already uses for a table's model. Queries
whose columns are exactly those of a table already return its model, such as
`Book`, without an annotation.
- The type name always wins: a query annotated with `:type` returns that type
even when its columns match a table's model.
8 changes: 8 additions & 0 deletions docs/reference/query-annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ command. The format of this comment is as follows:
-- name: <name> <command>
```

A query that returns rows may also name the type they are returned as with
`:type <TypeName>`, so that several queries can return the same type. See
[sharing a row type between queries](../howto/row_types.md).

```sql
-- name: <name> <command> :type <TypeName>
```

## `:exec`

The generated method will return the error from
Expand Down
1 change: 1 addition & 0 deletions docs/toc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ sections:
- howto/ddl.md
- howto/structs.md
- howto/embedding.md
- howto/row_types.md
- howto/overrides.md
- howto/rename.md

Expand Down
6 changes: 3 additions & 3 deletions internal/cmd/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,12 @@ Examples:
if err != nil {
return fmt.Errorf("failed to read statement source: %w", err)
}
name, cmd, err := metadata.ParseQueryNameAndType(rawSQL, commentSyntax)
md, err := metadata.ParseQueryNameAndType(rawSQL, commentSyntax)
if err != nil {
return fmt.Errorf("failed to parse query annotation: %w", err)
}
ps.Name = name
ps.Cmd = cmd
ps.Name = md.Name
ps.Cmd = md.Cmd
out = append(out, ps)
}

Expand Down
1 change: 1 addition & 0 deletions internal/cmd/shim.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ func pluginQueries(r *compiler.Result) []*plugin.Query {
Params: params,
Filename: q.Metadata.Filename,
InsertIntoTable: iit,
TypeName: q.Metadata.TypeName,
})
}
return out
Expand Down
89 changes: 87 additions & 2 deletions internal/codegen/golang/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ func argName(name string) string {
func buildQueries(req *plugin.GenerateRequest, options *opts.Options, enums []Enum, structs []Struct) ([]Query, error) {
models := buildModelTypeSet(enums, structs)
qualifier := options.ModelsTypeQualifier()
rowTypes := newRowTypes(structs)
qs := make([]Query, 0, len(req.Queries))
for _, query := range req.Queries {
if query.Name == "" {
Expand Down Expand Up @@ -269,6 +270,10 @@ func buildQueries(req *plugin.GenerateRequest, options *opts.Options, enums []En
}
}

if query.TypeName != "" && !returnsStruct(query) {
return nil, fmt.Errorf("query %s: :type %s needs a query that returns more than one column", query.Name, query.TypeName)
}

if len(query.Columns) == 1 && query.Columns[0].EmbedTable == nil {
c := query.Columns[0]
name := columnName(c, 0)
Expand Down Expand Up @@ -303,7 +308,7 @@ func buildQueries(req *plugin.GenerateRequest, options *opts.Options, enums []En
var emit bool

for _, s := range structs {
if len(s.Fields) != len(query.Columns) {
if query.TypeName != "" || len(s.Fields) != len(query.Columns) {
continue
}
same := true
Expand Down Expand Up @@ -331,12 +336,22 @@ func buildQueries(req *plugin.GenerateRequest, options *opts.Options, enums []En
embed: newGoEmbed(c.EmbedTable, structs, req.Catalog.DefaultSchema),
})
}
name := gq.MethodName + "Row"
if query.TypeName != "" {
name = query.TypeName
}
var err error
gs, err = columnsToStruct(req, options, gq.MethodName+"Row", columns, true, models, qualifier)
gs, err = columnsToStruct(req, options, name, columns, true, models, qualifier)
if err != nil {
return nil, err
}
emit = true
if query.TypeName != "" {
gs, emit, err = rowTypes.add(query.Name, gs)
if err != nil {
return nil, err
}
}
}
gq.Ret = QueryValue{
Emit: emit,
Expand All @@ -361,6 +376,76 @@ var cmdReturnsData = map[string]struct{}{
metadata.CmdOne: {},
}

// returnsStruct reports whether the Go code for query returns its rows as a
// struct, which is what a ":type" annotation names.
func returnsStruct(query *plugin.Query) bool {
if len(query.Columns) == 1 && query.Columns[0].EmbedTable == nil {
return false
}
return putOutColumns(query)
}

// rowTypes tracks the structs named by ":type" annotations, so that the
// queries sharing a name return one struct.
type rowTypes struct {
models map[string]bool
named map[string]rowType
}

type rowType struct {
query string // the first query to use the name, which emits the struct
s *Struct
}

func newRowTypes(models []Struct) *rowTypes {
r := &rowTypes{models: map[string]bool{}, named: map[string]rowType{}}
for _, m := range models {
r.models[m.Name] = true
}
return r
}

// add takes the struct built for the rows of a query annotated with ":type"
// and returns the struct the query returns and whether to emit it. The first
// query to use a name emits its struct; the others return that struct, as long
// as their columns give the same fields.
func (r *rowTypes) add(query string, s *Struct) (*Struct, bool, error) {
if r.models[s.Name] {
return nil, false, fmt.Errorf("query %s: :type %s is already the name of a model", query, s.Name)
}
first, ok := r.named[s.Name]
if !ok {
r.named[s.Name] = rowType{query: query, s: s}
return s, true, nil
}
if diff := fieldsDiff(first.s.Fields, s.Fields); diff != "" {
return nil, false, fmt.Errorf("query %s: :type %s does not match query %s: %s", query, s.Name, first.query, diff)
}
return first.s, false, nil
}

// fieldsDiff describes the first difference of got from want, or returns ""
// if they are the same.
func fieldsDiff(want, got []Field) string {
if len(got) != len(want) {
return fmt.Sprintf("%d columns, want %d", len(got), len(want))
}
for i, w := range want {
g := got[i]
if g.Name != w.Name || g.Type != w.Type || g.Tag() != w.Tag() || fieldsDiff(w.EmbedFields, g.EmbedFields) != "" {
return fmt.Sprintf("column %d is %s, want %s", i+1, describeField(g), describeField(w))
}
}
return ""
}

func describeField(f Field) string {
if tag := f.Tag(); tag != "" {
return fmt.Sprintf("%s %s `%s`", f.Name, f.Type, tag)
}
return f.Name + " " + f.Type
}

func putOutColumns(query *plugin.Query) bool {
_, found := cmdReturnsData[query.Cmd]
return found
Expand Down
11 changes: 3 additions & 8 deletions internal/compiler/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,24 +54,19 @@ func (c *Compiler) parseQuery(stmt ast.Node, pp *preprocess.Result, o opts.Parse
return nil, errors.New("missing semicolon at end of file")
}

name, cmd, err := metadata.ParseQueryNameAndType(rawSQL, metadata.CommentSyntax(c.parser.CommentSyntax()))
md, err := metadata.ParseQueryNameAndType(rawSQL, metadata.CommentSyntax(c.parser.CommentSyntax()))
if err != nil {
return nil, err
}

if name == "" {
if md.Name == "" {
return nil, nil
}

if err := validate.Cmd(raw.Stmt, name, cmd); err != nil {
if err := validate.Cmd(raw.Stmt, md.Name, md.Cmd); err != nil {
return nil, err
}

md := metadata.Metadata{
Name: name,
Cmd: cmd,
}

// TODO eventually can use this for name and type/cmd parsing too
cleanedComments, err := source.CleanedComments(rawSQL, c.parser.CommentSyntax())
if err != nil {
Expand Down
7 changes: 3 additions & 4 deletions internal/compiler/parse_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,17 @@ func (c *Compiler) parseQueryCore(raw *ast.RawStmt, src string, pre *preprocess.
return nil, errors.New("missing semicolon at end of file")
}

name, cmd, err := metadata.ParseQueryNameAndType(rawSQL, metadata.CommentSyntax(c.parser.CommentSyntax()))
md, err := metadata.ParseQueryNameAndType(rawSQL, metadata.CommentSyntax(c.parser.CommentSyntax()))
if err != nil {
return nil, err
}
if name == "" {
if md.Name == "" {
return nil, nil
}
if err := validate.Cmd(raw.Stmt, name, cmd); err != nil {
if err := validate.Cmd(raw.Stmt, md.Name, md.Cmd); err != nil {
return nil, err
}

md := metadata.Metadata{Name: name, Cmd: cmd}
cleanedComments, err := source.CleanedComments(rawSQL, c.parser.CommentSyntax())
if err != nil {
return nil, err
Expand Down
12 changes: 8 additions & 4 deletions internal/endtoend/testdata/codegen_json/gen/codegen.json
Original file line number Diff line number Diff line change
Expand Up @@ -66389,7 +66389,8 @@
],
"comments": [],
"filename": "query.sql",
"insert_into_table": null
"insert_into_table": null,
"type_name": ""
},
{
"text": "SELECT id, name, bio FROM authors\nORDER BY name",
Expand Down Expand Up @@ -66478,7 +66479,8 @@
"params": [],
"comments": [],
"filename": "query.sql",
"insert_into_table": null
"insert_into_table": null,
"type_name": ""
},
{
"text": "INSERT INTO authors (\n name, bio\n) VALUES (\n $1, $2\n)\nRETURNING id, name, bio",
Expand Down Expand Up @@ -66630,7 +66632,8 @@
"catalog": "",
"schema": "",
"name": "authors"
}
},
"type_name": ""
},
{
"text": "DELETE FROM authors\nWHERE id = $1",
Expand Down Expand Up @@ -66670,7 +66673,8 @@
],
"comments": [],
"filename": "query.sql",
"insert_into_table": null
"insert_into_table": null,
"type_name": ""
}
],
"sqlc_version": "v1.31.1",
Expand Down
Loading
Loading