diff --git a/internal/compiler/output_columns.go b/internal/compiler/output_columns.go index 557fb7d575..45f7a90f2e 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,90 @@ 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, 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) { + 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 + } + 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.count || find.other { + return false + } + } + return true +} + +// 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 { + count bool + other 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 { + return f + } + name := strings.ToLower(n.Func.Name) + if name == "count" { + f.count = true + } else if aggregates[name] { + f.other = 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 { + 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" 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..2666c1c05d --- /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, ...any) (pgconn.CommandTag, error) + Query(context.Context, string, ...any) (pgx.Rows, error) + QueryRow(context.Context, string, ...any) 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..3228ac06d3 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/go/query.sql.go @@ -0,0 +1,252 @@ +// 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 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 +` + +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 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 +` + +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..a9fc182588 --- /dev/null +++ b/internal/endtoend/testdata/nullable_scalar_subquery_aggregate/postgresql/pgx/query.sql @@ -0,0 +1,31 @@ +-- 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; + +-- 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; 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"