diff --git a/internal/compiler/output_columns.go b/internal/compiler/output_columns.go index 557fb7d575..723b4d107e 100644 --- a/internal/compiler/output_columns.go +++ b/internal/compiler/output_columns.go @@ -3,6 +3,7 @@ package compiler import ( "errors" "fmt" + "strings" "github.com/sqlc-dev/sqlc/internal/config" "github.com/sqlc-dev/sqlc/internal/sql/ast" @@ -349,11 +350,15 @@ func (c *Compiler) outputColumns(qc *QueryCatalog, node ast.Node) ([]*Column, er if err != nil { return nil, err } - first := subcols[0] + first := *subcols[0] if res.Name != nil { first.Name = *res.Name } - cols = append(cols, first) + // A scalar subquery that matches no row yields NULL + if !yieldsOneRow(n.Subselect) { + first.NotNull = false + } + cols = append(cols, &first) default: cols = append(cols, &Column{Name: name, DataType: "any", NotNull: false}) } @@ -810,3 +815,52 @@ func findColumnForRef(ref *ast.ColumnRef, tables []*Table, targetList *ast.List) return nil } + +// aggregates names the aggregate functions that return a row even when no row +// matches, as count(*) does +var aggregates = map[string]bool{ + "count": true, "sum": true, "avg": true, "min": true, "max": true, + "bool_and": true, "bool_or": true, "every": true, "array_agg": true, + "string_agg": true, "json_agg": true, "jsonb_agg": true, + "json_object_agg": true, "jsonb_object_agg": true, +} + +// yieldsOneRow reports whether a scalar subquery always yields exactly one row: +// a SELECT with no FROM or WHERE, or one whose every column is an aggregate, +// with no GROUP BY, HAVING, LIMIT, OFFSET or set operation. +func yieldsOneRow(node ast.Node) bool { + sel, ok := node.(*ast.SelectStmt) + if !ok || sel.Op != ast.None || given(sel.LimitCount) || given(sel.LimitOffset) || given(sel.HavingClause) { + return false + } + if sel.GroupClause != nil && len(sel.GroupClause.Items) > 0 { + return false + } + if (sel.FromClause == nil || len(sel.FromClause.Items) == 0) && !given(sel.WhereClause) { + return true + } + if sel.TargetList == nil || len(sel.TargetList.Items) == 0 { + return false + } + for _, item := range sel.TargetList.Items { + res, ok := item.(*ast.ResTarget) + if !ok { + return false + } + call, ok := res.Val.(*ast.FuncCall) + if !ok || call.Func == nil || call.Over != nil || !aggregates[strings.ToLower(call.Func.Name)] { + return false + } + } + return true +} + +// given reports whether a clause was written: the parser leaves one that wasn't +// as nil or as a placeholder +func given(n ast.Node) bool { + if n == nil { + return false + } + _, placeholder := n.(*ast.TODO) + return !placeholder +} diff --git a/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/issue.md b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/issue.md new file mode 100644 index 0000000000..937656a958 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/issue.md @@ -0,0 +1 @@ +https://github.com/sqlc-dev/sqlc/issues/4262 diff --git a/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/go/db.go b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/go/db.go new file mode 100644 index 0000000000..32faa9f2b8 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/go/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/go/models.go b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/go/models.go new file mode 100644 index 0000000000..9604257721 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/go/models.go @@ -0,0 +1,25 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "database/sql" +) + +type Book struct { + ID int32 + Title string +} + +type BookView struct { + ID int32 + ReviewID sql.NullInt32 + Reviews int64 +} + +type Review struct { + ID int32 + BookID int32 +} diff --git a/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/go/query.sql.go b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/go/query.sql.go new file mode 100644 index 0000000000..96fbb24423 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/go/query.sql.go @@ -0,0 +1,74 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" + "database/sql" +) + +const inline = `-- name: Inline :many +select (select r.id from reviews r where r.book_id = b.id limit 1) review_id, + (select count(*) from reviews r where r.book_id = b.id) reviews, + (select 1) one +from books b +` + +type InlineRow struct { + ReviewID sql.NullInt32 + Reviews int64 + One int32 +} + +func (q *Queries) Inline(ctx context.Context) ([]InlineRow, error) { + rows, err := q.db.QueryContext(ctx, inline) + if err != nil { + return nil, err + } + defer rows.Close() + var items []InlineRow + for rows.Next() { + var i InlineRow + if err := rows.Scan(&i.ReviewID, &i.Reviews, &i.One); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const throughView = `-- name: ThroughView :many +select id, review_id, reviews from book_view +` + +func (q *Queries) ThroughView(ctx context.Context) ([]BookView, error) { + rows, err := q.db.QueryContext(ctx, throughView) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BookView + for rows.Next() { + var i BookView + if err := rows.Scan(&i.ID, &i.ReviewID, &i.Reviews); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/query.sql b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/query.sql new file mode 100644 index 0000000000..cc29cb1399 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/query.sql @@ -0,0 +1,8 @@ +-- name: ThroughView :many +select id, review_id, reviews from book_view; + +-- name: Inline :many +select (select r.id from reviews r where r.book_id = b.id limit 1) review_id, + (select count(*) from reviews r where r.book_id = b.id) reviews, + (select 1) one +from books b; diff --git a/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/schema.sql b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/schema.sql new file mode 100644 index 0000000000..601f2cfac3 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/schema.sql @@ -0,0 +1,6 @@ +create table books (id int primary key, title text not null); +create table reviews (id int primary key, book_id int not null); +create view book_view as + select b.id, (select r.id from reviews r where r.book_id = b.id limit 1) review_id, + (select count(*) from reviews r where r.book_id = b.id) reviews + from books b; diff --git a/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/sqlc.yaml b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/sqlc.yaml new file mode 100644 index 0000000000..8c68222b49 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: "postgresql" + schema: "schema.sql" + queries: "query.sql" + gen: + go: + package: "querytest" + out: "go" + sql_package: "database/sql"