From 0bb6a6e79193852b30c5963cacd245387fe49899 Mon Sep 17 00:00:00 2001 From: Ken Britton Date: Sat, 24 Oct 2026 06:29:31 -0700 Subject: [PATCH 1/4] compiler: a scalar subquery's column is nullable A scalar subquery yields NULL when it matches no row, so its column is nullable unless the subquery always yields one row: an aggregate with no GROUP BY, HAVING or LIMIT, or a SELECT with no FROM. Fixes #4262 Co-Authored-By: Claude Opus 5.5 --- internal/compiler/output_columns.go | 58 ++++++++++++++- .../postgresql/issue.md | 1 + .../postgresql/stdlib/go/db.go | 31 ++++++++ .../postgresql/stdlib/go/models.go | 25 +++++++ .../postgresql/stdlib/go/query.sql.go | 74 +++++++++++++++++++ .../postgresql/stdlib/query.sql | 8 ++ .../postgresql/stdlib/schema.sql | 6 ++ .../postgresql/stdlib/sqlc.yaml | 10 +++ 8 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery/postgresql/issue.md create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/go/db.go create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/go/models.go create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/go/query.sql.go create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/query.sql create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/schema.sql create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery/postgresql/stdlib/sqlc.yaml 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" From 540c5b901a8db9ce35b76736fdddff8ef72ea537 Mon Sep 17 00:00:00 2001 From: James Hiscock Date: Sat, 14 Nov 2026 11:08:45 +1100 Subject: [PATCH 2/4] compiler: an aggregate inside an expression still yields one row The previous commit keeps a scalar subquery's column never null only when the column is a bare aggregate call. `(SELECT count(*)::int FROM ...)` and `(SELECT count(*) + 1 FROM ...)` are aggregates with no GROUP BY too, and always yield one row, but were reported as nullable. Look for the aggregate anywhere in the column's expression, outside nested subqueries and window functions. Co-Authored-By: Claude Fable 5.2 --- internal/compiler/output_columns.go | 29 ++- .../postgresql/pgx/go/db.go | 32 +++ .../postgresql/pgx/go/models.go | 28 +++ .../postgresql/pgx/go/query.sql.go | 192 ++++++++++++++++++ .../postgresql/pgx/query.sql | 23 +++ .../postgresql/pgx/schema.sql | 18 ++ .../postgresql/pgx/sqlc.yaml | 10 + 7 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/db.go create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/models.go create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/query.sql.go create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/query.sql create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/schema.sql create mode 100644 internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/sqlc.yaml diff --git a/internal/compiler/output_columns.go b/internal/compiler/output_columns.go index 723b4d107e..84a3eb511e 100644 --- a/internal/compiler/output_columns.go +++ b/internal/compiler/output_columns.go @@ -826,8 +826,9 @@ var aggregates = map[string]bool{ } // 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. +// a SELECT with no FROM or WHERE, or one whose every column holds an aggregate, +// bare or inside an expression such as a cast, 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) { @@ -847,14 +848,34 @@ func yieldsOneRow(node ast.Node) bool { if !ok { return false } - call, ok := res.Val.(*ast.FuncCall) - if !ok || call.Func == nil || call.Over != nil || !aggregates[strings.ToLower(call.Func.Name)] { + var find aggregateFinder + astutils.Walk(&find, res.Val) + if !find.found { return false } } return true } +// aggregateFinder looks for an aggregate call in an expression. It doesn't look +// inside subqueries, which are query levels of their own, and a window function +// is not an aggregate. +type aggregateFinder struct { + found bool +} + +func (f *aggregateFinder) Visit(n ast.Node) astutils.Visitor { + switch n := n.(type) { + case *ast.SubLink, *ast.SelectStmt: + return nil + case *ast.FuncCall: + if n.Func != nil && n.Over == nil && aggregates[strings.ToLower(n.Func.Name)] { + f.found = true + } + } + return f +} + // 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 { diff --git a/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/db.go b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/db.go new file mode 100644 index 0000000000..0057c62319 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/models.go b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/models.go new file mode 100644 index 0000000000..4e1339fe1d --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/models.go @@ -0,0 +1,28 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type Depot struct { + ID int64 + Name string + Note pgtype.Text +} + +type Tracker struct { + ID int64 + TruckID int64 + Active bool + Serial pgtype.Text +} + +type Truck struct { + ID int64 + DepotID int64 + Plate string +} diff --git a/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/query.sql.go b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/query.sql.go new file mode 100644 index 0000000000..7f36beec65 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/query.sql.go @@ -0,0 +1,192 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const aggregateInExpression = `-- name: AggregateInExpression :many +SELECT t.id, (SELECT count(*)::int + 1 FROM trackers k WHERE k.truck_id = t.id) AS trackers +FROM trucks t +` + +type AggregateInExpressionRow struct { + ID int64 + Trackers int32 +} + +func (q *Queries) AggregateInExpression(ctx context.Context) ([]AggregateInExpressionRow, error) { + rows, err := q.db.Query(ctx, aggregateInExpression) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AggregateInExpressionRow + for rows.Next() { + var i AggregateInExpressionRow + if err := rows.Scan(&i.ID, &i.Trackers); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const aggregateInNestedSubquery = `-- name: AggregateInNestedSubquery :many +SELECT t.id, (SELECT d.id::int + (SELECT count(*)::int FROM trackers) FROM depots d WHERE d.id = t.depot_id) AS n +FROM trucks t +` + +type AggregateInNestedSubqueryRow struct { + ID int64 + N pgtype.Int4 +} + +func (q *Queries) AggregateInNestedSubquery(ctx context.Context) ([]AggregateInNestedSubqueryRow, error) { + rows, err := q.db.Query(ctx, aggregateInNestedSubquery) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AggregateInNestedSubqueryRow + for rows.Next() { + var i AggregateInNestedSubqueryRow + if err := rows.Scan(&i.ID, &i.N); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const aggregateUnderCast = `-- name: AggregateUnderCast :many +SELECT t.id, (SELECT count(*)::int FROM trackers k WHERE k.truck_id = t.id) AS trackers +FROM trucks t +` + +type AggregateUnderCastRow struct { + ID int64 + Trackers int32 +} + +func (q *Queries) AggregateUnderCast(ctx context.Context) ([]AggregateUnderCastRow, error) { + rows, err := q.db.Query(ctx, aggregateUnderCast) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AggregateUnderCastRow + for rows.Next() { + var i AggregateUnderCastRow + if err := rows.Scan(&i.ID, &i.Trackers); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const groupedAggregate = `-- name: GroupedAggregate :many +SELECT t.id, (SELECT count(*) FROM trackers k WHERE k.truck_id = t.id GROUP BY k.active LIMIT 1) AS trackers +FROM trucks t +` + +type GroupedAggregateRow struct { + ID int64 + Trackers pgtype.Int8 +} + +func (q *Queries) GroupedAggregate(ctx context.Context) ([]GroupedAggregateRow, error) { + rows, err := q.db.Query(ctx, groupedAggregate) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GroupedAggregateRow + for rows.Next() { + var i GroupedAggregateRow + if err := rows.Scan(&i.ID, &i.Trackers); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const plainFunction = `-- name: PlainFunction :many +SELECT t.id, (SELECT upper(d.name)::text FROM depots d WHERE d.id = t.depot_id) AS depot +FROM trucks t +` + +type PlainFunctionRow struct { + ID int64 + Depot pgtype.Text +} + +func (q *Queries) PlainFunction(ctx context.Context) ([]PlainFunctionRow, error) { + rows, err := q.db.Query(ctx, plainFunction) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PlainFunctionRow + for rows.Next() { + var i PlainFunctionRow + if err := rows.Scan(&i.ID, &i.Depot); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const windowFunction = `-- name: WindowFunction :many +SELECT t.id, (SELECT count(*) OVER () FROM depots d WHERE d.id = t.depot_id) AS n +FROM trucks t +` + +type WindowFunctionRow struct { + ID int64 + N pgtype.Int8 +} + +func (q *Queries) WindowFunction(ctx context.Context) ([]WindowFunctionRow, error) { + rows, err := q.db.Query(ctx, windowFunction) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WindowFunctionRow + for rows.Next() { + var i WindowFunctionRow + if err := rows.Scan(&i.ID, &i.N); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/query.sql b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/query.sql new file mode 100644 index 0000000000..f013f6efd4 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/query.sql @@ -0,0 +1,23 @@ +-- name: AggregateUnderCast :many +SELECT t.id, (SELECT count(*)::int FROM trackers k WHERE k.truck_id = t.id) AS trackers +FROM trucks t; + +-- name: AggregateInExpression :many +SELECT t.id, (SELECT count(*)::int + 1 FROM trackers k WHERE k.truck_id = t.id) AS trackers +FROM trucks t; + +-- name: GroupedAggregate :many +SELECT t.id, (SELECT count(*) FROM trackers k WHERE k.truck_id = t.id GROUP BY k.active LIMIT 1) AS trackers +FROM trucks t; + +-- name: PlainFunction :many +SELECT t.id, (SELECT upper(d.name)::text FROM depots d WHERE d.id = t.depot_id) AS depot +FROM trucks t; + +-- name: AggregateInNestedSubquery :many +SELECT t.id, (SELECT d.id::int + (SELECT count(*)::int FROM trackers) FROM depots d WHERE d.id = t.depot_id) AS n +FROM trucks t; + +-- name: WindowFunction :many +SELECT t.id, (SELECT count(*) OVER () FROM depots d WHERE d.id = t.depot_id) AS n +FROM trucks t; diff --git a/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/schema.sql b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/schema.sql new file mode 100644 index 0000000000..6b38e8f9a5 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/schema.sql @@ -0,0 +1,18 @@ +CREATE TABLE depots ( + id BIGSERIAL PRIMARY KEY, + name text NOT NULL, + note text +); + +CREATE TABLE trucks ( + id BIGSERIAL PRIMARY KEY, + depot_id bigint NOT NULL, + plate text NOT NULL +); + +CREATE TABLE trackers ( + id BIGSERIAL PRIMARY KEY, + truck_id bigint NOT NULL, + active boolean NOT NULL, + serial text +); diff --git a/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/sqlc.yaml b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/sqlc.yaml new file mode 100644 index 0000000000..5dc63e3f91 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/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: "pgx/v5" From 7eb8516a9eabf7ee32ac6fa88d8b1a09974c9f0c Mon Sep 17 00:00:00 2001 From: James Hiscock Date: Sat, 14 Nov 2026 11:39:07 +1100 Subject: [PATCH 3/4] compiler: only count keeps an expression in a scalar subquery never null The commit before last kept a scalar subquery's column never null when an aggregate sat anywhere in its expression. That is right for count, which is never NULL, and wrong for the rest: `(SELECT max(x)::int FROM ...)` yields one row, and that row is NULL when nothing matched, but a cast reports its column as never null. Keep the column never null only for an expression whose aggregates are all count. Co-Authored-By: Claude Fable 5.2 --- internal/compiler/output_columns.go | 33 +++++++--- .../postgresql/pgx/go/query.sql.go | 60 +++++++++++++++++++ .../postgresql/pgx/query.sql | 8 +++ 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/internal/compiler/output_columns.go b/internal/compiler/output_columns.go index 84a3eb511e..45f7a90f2e 100644 --- a/internal/compiler/output_columns.go +++ b/internal/compiler/output_columns.go @@ -826,9 +826,9 @@ var aggregates = map[string]bool{ } // yieldsOneRow reports whether a scalar subquery always yields exactly one row: -// a SELECT with no FROM or WHERE, or one whose every column holds an aggregate, -// bare or inside an expression such as a cast, with no GROUP BY, HAVING, LIMIT, -// OFFSET or set operation. +// a SELECT with no FROM or WHERE, or one whose every column is an aggregate, or +// an expression over count such as a cast of it, 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) { @@ -848,20 +848,31 @@ func yieldsOneRow(node ast.Node) bool { if !ok { return false } + if call, ok := res.Val.(*ast.FuncCall); ok { + if call.Func == nil || call.Over != nil || !aggregates[strings.ToLower(call.Func.Name)] { + return false + } + continue + } + // An expression over count, such as count(*)::int, is an aggregate + // too and can't be NULL. One over any other aggregate yields a row + // as well, but that row is NULL when nothing matched, and the + // expression's own column doesn't say so. var find aggregateFinder astutils.Walk(&find, res.Val) - if !find.found { + if !find.count || find.other { return false } } return true } -// aggregateFinder looks for an aggregate call in an expression. It doesn't look +// aggregateFinder looks for aggregate calls in an expression. It doesn't look // inside subqueries, which are query levels of their own, and a window function // is not an aggregate. type aggregateFinder struct { - found bool + count bool + other bool } func (f *aggregateFinder) Visit(n ast.Node) astutils.Visitor { @@ -869,8 +880,14 @@ func (f *aggregateFinder) Visit(n ast.Node) astutils.Visitor { case *ast.SubLink, *ast.SelectStmt: return nil case *ast.FuncCall: - if n.Func != nil && n.Over == nil && aggregates[strings.ToLower(n.Func.Name)] { - f.found = true + if n.Func == nil || n.Over != nil { + return f + } + name := strings.ToLower(n.Func.Name) + if name == "count" { + f.count = true + } else if aggregates[name] { + f.other = true } } return f diff --git a/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/query.sql.go b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/query.sql.go index 7f36beec65..3228ac06d3 100644 --- a/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/query.sql.go +++ b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/query.sql.go @@ -101,6 +101,36 @@ func (q *Queries) AggregateUnderCast(ctx context.Context) ([]AggregateUnderCastR return items, nil } +const countWithAnotherAggregate = `-- name: CountWithAnotherAggregate :many +SELECT t.id, (SELECT count(*)::int + max(k.id)::int FROM trackers k WHERE k.truck_id = t.id) AS n +FROM trucks t +` + +type CountWithAnotherAggregateRow struct { + ID int64 + N pgtype.Int4 +} + +func (q *Queries) CountWithAnotherAggregate(ctx context.Context) ([]CountWithAnotherAggregateRow, error) { + rows, err := q.db.Query(ctx, countWithAnotherAggregate) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CountWithAnotherAggregateRow + for rows.Next() { + var i CountWithAnotherAggregateRow + if err := rows.Scan(&i.ID, &i.N); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const groupedAggregate = `-- name: GroupedAggregate :many SELECT t.id, (SELECT count(*) FROM trackers k WHERE k.truck_id = t.id GROUP BY k.active LIMIT 1) AS trackers FROM trucks t @@ -131,6 +161,36 @@ func (q *Queries) GroupedAggregate(ctx context.Context) ([]GroupedAggregateRow, return items, nil } +const otherAggregateUnderCast = `-- name: OtherAggregateUnderCast :many +SELECT t.id, (SELECT max(k.id)::int FROM trackers k WHERE k.truck_id = t.id) AS newest +FROM trucks t +` + +type OtherAggregateUnderCastRow struct { + ID int64 + Newest pgtype.Int4 +} + +func (q *Queries) OtherAggregateUnderCast(ctx context.Context) ([]OtherAggregateUnderCastRow, error) { + rows, err := q.db.Query(ctx, otherAggregateUnderCast) + if err != nil { + return nil, err + } + defer rows.Close() + var items []OtherAggregateUnderCastRow + for rows.Next() { + var i OtherAggregateUnderCastRow + if err := rows.Scan(&i.ID, &i.Newest); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const plainFunction = `-- name: PlainFunction :many SELECT t.id, (SELECT upper(d.name)::text FROM depots d WHERE d.id = t.depot_id) AS depot FROM trucks t diff --git a/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/query.sql b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/query.sql index f013f6efd4..a9fc182588 100644 --- a/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/query.sql +++ b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/query.sql @@ -21,3 +21,11 @@ FROM trucks t; -- name: WindowFunction :many SELECT t.id, (SELECT count(*) OVER () FROM depots d WHERE d.id = t.depot_id) AS n FROM trucks t; + +-- name: OtherAggregateUnderCast :many +SELECT t.id, (SELECT max(k.id)::int FROM trackers k WHERE k.truck_id = t.id) AS newest +FROM trucks t; + +-- name: CountWithAnotherAggregate :many +SELECT t.id, (SELECT count(*)::int + max(k.id)::int FROM trackers k WHERE k.truck_id = t.id) AS n +FROM trucks t; From e9724628da07e55e9d20b861bd88e69314e25e0b Mon Sep 17 00:00:00 2001 From: Ken Britton Date: Sat, 14 Nov 2026 06:09:14 -0700 Subject: [PATCH 4/4] endtoend: regenerate nullable_scalar_subquery_aggregate on main Co-Authored-By: Claude Opus 5.5 --- .../postgresql/pgx/go/db.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/db.go b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/db.go index 0057c62319..2666c1c05d 100644 --- a/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/db.go +++ b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/db.go @@ -12,9 +12,9 @@ import ( ) type DBTX interface { - Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) - Query(context.Context, string, ...interface{}) (pgx.Rows, error) - QueryRow(context.Context, string, ...interface{}) pgx.Row + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) + Query(context.Context, string, ...any) (pgx.Rows, error) + QueryRow(context.Context, string, ...any) pgx.Row } func New(db DBTX) *Queries {