Skip to content

Commit e467f17

Browse files
egimbernatali
andcommitted
Add a :type annotation to share a row type between queries
A query that returns more than one column gets a struct named after it, so queries selecting the same columns return different types. `:type <TypeName>` after the command names the row type instead, and queries that use the same name return one struct. Generation fails if their columns give different fields, if the name is a model's, or if the query returns a single column. Plugins receive the name as Query.type_name. ParseQueryNameAndType now returns the Metadata, so its callers no longer rebuild it from parts. Fixes #781 Fixes #3595 Co-authored-by: ali <ali.dehkharghani@megadevs.de>
1 parent bdbe55d commit e467f17

36 files changed

Lines changed: 692 additions & 82 deletions

File tree

‎docs/howto/row_types.md‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Sharing a row type between queries
2+
3+
sqlc generates a struct for the rows of each query that returns more than one
4+
column, named after the query. Queries that select the same columns therefore
5+
return different types, even though the structs are identical.
6+
7+
```sql
8+
CREATE TABLE authors (
9+
id BIGSERIAL PRIMARY KEY,
10+
name text NOT NULL
11+
);
12+
13+
CREATE TABLE books (
14+
id BIGSERIAL PRIMARY KEY,
15+
author_id bigint NOT NULL REFERENCES authors (id),
16+
title text NOT NULL
17+
);
18+
```
19+
20+
```sql
21+
-- name: GetBook :one
22+
SELECT books.id, books.title, authors.name AS author_name
23+
FROM books
24+
JOIN authors ON authors.id = books.author_id
25+
WHERE books.id = $1;
26+
27+
-- name: ListBooksByAuthor :many
28+
SELECT books.id, books.title, authors.name AS author_name
29+
FROM books
30+
JOIN authors ON authors.id = books.author_id
31+
WHERE books.author_id = $1;
32+
```
33+
34+
```go
35+
type GetBookRow struct {
36+
ID int64
37+
Title string
38+
AuthorName string
39+
}
40+
41+
type ListBooksByAuthorRow struct {
42+
ID int64
43+
Title string
44+
AuthorName string
45+
}
46+
```
47+
48+
To have them return one type, name it with `:type <TypeName>` after the
49+
command:
50+
51+
```sql
52+
-- name: GetBook :one :type BookWithAuthor
53+
SELECT books.id, books.title, authors.name AS author_name
54+
FROM books
55+
JOIN authors ON authors.id = books.author_id
56+
WHERE books.id = $1;
57+
58+
-- name: ListBooksByAuthor :many :type BookWithAuthor
59+
SELECT books.id, books.title, authors.name AS author_name
60+
FROM books
61+
JOIN authors ON authors.id = books.author_id
62+
WHERE books.author_id = $1;
63+
```
64+
65+
```go
66+
type BookWithAuthor struct {
67+
ID int64
68+
Title string
69+
AuthorName string
70+
}
71+
72+
func (q *Queries) GetBook(ctx context.Context, id int64) (BookWithAuthor, error) {
73+
// ...
74+
}
75+
76+
func (q *Queries) ListBooksByAuthor(ctx context.Context, authorID int64) ([]BookWithAuthor, error) {
77+
// ...
78+
}
79+
```
80+
81+
The type name works with [embedded structs](embedding.md) too, and a single
82+
query can use it just to choose the name of its row type.
83+
84+
## Rules
85+
86+
- Every query that names a type must return the same fields, in the same
87+
order, with the same Go types and struct tags. Otherwise `sqlc generate`
88+
fails and says which column differs:
89+
90+
```
91+
query ListBooks: :type BookWithAuthor does not match query GetBook: column 3 is AuthorID int64, want AuthorName string
92+
```
93+
94+
- The query must return more than one column, since a query with a single
95+
column returns that column's value instead of a struct.
96+
- The name must not be one sqlc already uses for a table's model. Queries
97+
whose columns are exactly those of a table already return its model, such as
98+
`Book`, without an annotation.
99+
- The type name always wins: a query annotated with `:type` returns that type
100+
even when its columns match a table's model.

‎docs/reference/query-annotations.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ command. The format of this comment is as follows:
77
-- name: <name> <command>
88
```
99

10+
A query that returns rows may also name the type they are returned as with
11+
`:type <TypeName>`, so that several queries can return the same type. See
12+
[sharing a row type between queries](../howto/row_types.md).
13+
14+
```sql
15+
-- name: <name> <command> :type <TypeName>
16+
```
17+
1018
## `:exec`
1119

1220
The generated method will return the error from

‎docs/toc.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ sections:
4040
- howto/ddl.md
4141
- howto/structs.md
4242
- howto/embedding.md
43+
- howto/row_types.md
4344
- howto/overrides.md
4445
- howto/rename.md
4546

‎internal/cmd/parse.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,12 @@ Examples:
142142
if err != nil {
143143
return fmt.Errorf("failed to read statement source: %w", err)
144144
}
145-
name, cmd, err := metadata.ParseQueryNameAndType(rawSQL, commentSyntax)
145+
md, err := metadata.ParseQueryNameAndType(rawSQL, commentSyntax)
146146
if err != nil {
147147
return fmt.Errorf("failed to parse query annotation: %w", err)
148148
}
149-
ps.Name = name
150-
ps.Cmd = cmd
149+
ps.Name = md.Name
150+
ps.Cmd = md.Cmd
151151
out = append(out, ps)
152152
}
153153

‎internal/cmd/shim.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ func pluginQueries(r *compiler.Result) []*plugin.Query {
161161
Params: params,
162162
Filename: q.Metadata.Filename,
163163
InsertIntoTable: iit,
164+
TypeName: q.Metadata.TypeName,
164165
})
165166
}
166167
return out

‎internal/codegen/golang/result.go‎

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ func argName(name string) string {
185185
func buildQueries(req *plugin.GenerateRequest, options *opts.Options, enums []Enum, structs []Struct) ([]Query, error) {
186186
models := buildModelTypeSet(enums, structs)
187187
qualifier := options.ModelsTypeQualifier()
188+
rowTypes := newRowTypes(structs)
188189
qs := make([]Query, 0, len(req.Queries))
189190
for _, query := range req.Queries {
190191
if query.Name == "" {
@@ -269,6 +270,10 @@ func buildQueries(req *plugin.GenerateRequest, options *opts.Options, enums []En
269270
}
270271
}
271272

273+
if query.TypeName != "" && !returnsStruct(query) {
274+
return nil, fmt.Errorf("query %s: :type %s needs a query that returns more than one column", query.Name, query.TypeName)
275+
}
276+
272277
if len(query.Columns) == 1 && query.Columns[0].EmbedTable == nil {
273278
c := query.Columns[0]
274279
name := columnName(c, 0)
@@ -303,7 +308,7 @@ func buildQueries(req *plugin.GenerateRequest, options *opts.Options, enums []En
303308
var emit bool
304309

305310
for _, s := range structs {
306-
if len(s.Fields) != len(query.Columns) {
311+
if query.TypeName != "" || len(s.Fields) != len(query.Columns) {
307312
continue
308313
}
309314
same := true
@@ -331,12 +336,22 @@ func buildQueries(req *plugin.GenerateRequest, options *opts.Options, enums []En
331336
embed: newGoEmbed(c.EmbedTable, structs, req.Catalog.DefaultSchema),
332337
})
333338
}
339+
name := gq.MethodName + "Row"
340+
if query.TypeName != "" {
341+
name = query.TypeName
342+
}
334343
var err error
335-
gs, err = columnsToStruct(req, options, gq.MethodName+"Row", columns, true, models, qualifier)
344+
gs, err = columnsToStruct(req, options, name, columns, true, models, qualifier)
336345
if err != nil {
337346
return nil, err
338347
}
339348
emit = true
349+
if query.TypeName != "" {
350+
gs, emit, err = rowTypes.add(query.Name, gs)
351+
if err != nil {
352+
return nil, err
353+
}
354+
}
340355
}
341356
gq.Ret = QueryValue{
342357
Emit: emit,
@@ -361,6 +376,76 @@ var cmdReturnsData = map[string]struct{}{
361376
metadata.CmdOne: {},
362377
}
363378

379+
// returnsStruct reports whether the Go code for query returns its rows as a
380+
// struct, which is what a ":type" annotation names.
381+
func returnsStruct(query *plugin.Query) bool {
382+
if len(query.Columns) == 1 && query.Columns[0].EmbedTable == nil {
383+
return false
384+
}
385+
return putOutColumns(query)
386+
}
387+
388+
// rowTypes tracks the structs named by ":type" annotations, so that the
389+
// queries sharing a name return one struct.
390+
type rowTypes struct {
391+
models map[string]bool
392+
named map[string]rowType
393+
}
394+
395+
type rowType struct {
396+
query string // the first query to use the name, which emits the struct
397+
s *Struct
398+
}
399+
400+
func newRowTypes(models []Struct) *rowTypes {
401+
r := &rowTypes{models: map[string]bool{}, named: map[string]rowType{}}
402+
for _, m := range models {
403+
r.models[m.Name] = true
404+
}
405+
return r
406+
}
407+
408+
// add takes the struct built for the rows of a query annotated with ":type"
409+
// and returns the struct the query returns and whether to emit it. The first
410+
// query to use a name emits its struct; the others return that struct, as long
411+
// as their columns give the same fields.
412+
func (r *rowTypes) add(query string, s *Struct) (*Struct, bool, error) {
413+
if r.models[s.Name] {
414+
return nil, false, fmt.Errorf("query %s: :type %s is already the name of a model", query, s.Name)
415+
}
416+
first, ok := r.named[s.Name]
417+
if !ok {
418+
r.named[s.Name] = rowType{query: query, s: s}
419+
return s, true, nil
420+
}
421+
if diff := fieldsDiff(first.s.Fields, s.Fields); diff != "" {
422+
return nil, false, fmt.Errorf("query %s: :type %s does not match query %s: %s", query, s.Name, first.query, diff)
423+
}
424+
return first.s, false, nil
425+
}
426+
427+
// fieldsDiff describes the first difference of got from want, or returns ""
428+
// if they are the same.
429+
func fieldsDiff(want, got []Field) string {
430+
if len(got) != len(want) {
431+
return fmt.Sprintf("%d columns, want %d", len(got), len(want))
432+
}
433+
for i, w := range want {
434+
g := got[i]
435+
if g.Name != w.Name || g.Type != w.Type || g.Tag() != w.Tag() || fieldsDiff(w.EmbedFields, g.EmbedFields) != "" {
436+
return fmt.Sprintf("column %d is %s, want %s", i+1, describeField(g), describeField(w))
437+
}
438+
}
439+
return ""
440+
}
441+
442+
func describeField(f Field) string {
443+
if tag := f.Tag(); tag != "" {
444+
return fmt.Sprintf("%s %s `%s`", f.Name, f.Type, tag)
445+
}
446+
return f.Name + " " + f.Type
447+
}
448+
364449
func putOutColumns(query *plugin.Query) bool {
365450
_, found := cmdReturnsData[query.Cmd]
366451
return found

‎internal/compiler/parse.go‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,24 +54,19 @@ func (c *Compiler) parseQuery(stmt ast.Node, pp *preprocess.Result, o opts.Parse
5454
return nil, errors.New("missing semicolon at end of file")
5555
}
5656

57-
name, cmd, err := metadata.ParseQueryNameAndType(rawSQL, metadata.CommentSyntax(c.parser.CommentSyntax()))
57+
md, err := metadata.ParseQueryNameAndType(rawSQL, metadata.CommentSyntax(c.parser.CommentSyntax()))
5858
if err != nil {
5959
return nil, err
6060
}
6161

62-
if name == "" {
62+
if md.Name == "" {
6363
return nil, nil
6464
}
6565

66-
if err := validate.Cmd(raw.Stmt, name, cmd); err != nil {
66+
if err := validate.Cmd(raw.Stmt, md.Name, md.Cmd); err != nil {
6767
return nil, err
6868
}
6969

70-
md := metadata.Metadata{
71-
Name: name,
72-
Cmd: cmd,
73-
}
74-
7570
// TODO eventually can use this for name and type/cmd parsing too
7671
cleanedComments, err := source.CleanedComments(rawSQL, c.parser.CommentSyntax())
7772
if err != nil {

‎internal/compiler/parse_core.go‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,17 @@ func (c *Compiler) parseQueryCore(raw *ast.RawStmt, src string, pre *preprocess.
2424
return nil, errors.New("missing semicolon at end of file")
2525
}
2626

27-
name, cmd, err := metadata.ParseQueryNameAndType(rawSQL, metadata.CommentSyntax(c.parser.CommentSyntax()))
27+
md, err := metadata.ParseQueryNameAndType(rawSQL, metadata.CommentSyntax(c.parser.CommentSyntax()))
2828
if err != nil {
2929
return nil, err
3030
}
31-
if name == "" {
31+
if md.Name == "" {
3232
return nil, nil
3333
}
34-
if err := validate.Cmd(raw.Stmt, name, cmd); err != nil {
34+
if err := validate.Cmd(raw.Stmt, md.Name, md.Cmd); err != nil {
3535
return nil, err
3636
}
3737

38-
md := metadata.Metadata{Name: name, Cmd: cmd}
3938
cleanedComments, err := source.CleanedComments(rawSQL, c.parser.CommentSyntax())
4039
if err != nil {
4140
return nil, err

‎internal/endtoend/testdata/codegen_json/gen/codegen.json‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66389,7 +66389,8 @@
6638966389
],
6639066390
"comments": [],
6639166391
"filename": "query.sql",
66392-
"insert_into_table": null
66392+
"insert_into_table": null,
66393+
"type_name": ""
6639366394
},
6639466395
{
6639566396
"text": "SELECT id, name, bio FROM authors\nORDER BY name",
@@ -66478,7 +66479,8 @@
6647866479
"params": [],
6647966480
"comments": [],
6648066481
"filename": "query.sql",
66481-
"insert_into_table": null
66482+
"insert_into_table": null,
66483+
"type_name": ""
6648266484
},
6648366485
{
6648466486
"text": "INSERT INTO authors (\n name, bio\n) VALUES (\n $1, $2\n)\nRETURNING id, name, bio",
@@ -66630,7 +66632,8 @@
6663066632
"catalog": "",
6663166633
"schema": "",
6663266634
"name": "authors"
66633-
}
66635+
},
66636+
"type_name": ""
6663466637
},
6663566638
{
6663666639
"text": "DELETE FROM authors\nWHERE id = $1",
@@ -66670,7 +66673,8 @@
6667066673
],
6667166674
"comments": [],
6667266675
"filename": "query.sql",
66673-
"insert_into_table": null
66676+
"insert_into_table": null,
66677+
"type_name": ""
6667466678
}
6667566679
],
6667666680
"sqlc_version": "v1.31.1",

0 commit comments

Comments
 (0)