Skip to content

Commit 36d4028

Browse files
authored
Merge pull request #58 from sqlcj-dev/feature/grouped-count-and-cast-projections
feat(analysis): allow grouped counts and cast-typed projections
2 parents e110322 + a2ab464 commit 36d4028

4 files changed

Lines changed: 418 additions & 36 deletions

File tree

‎docs/queries.md‎

Lines changed: 76 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -104,22 +104,27 @@ configuration entry.
104104
positional row mapper.
105105
- An unqualified column must be found in exactly one source; an ambiguous or
106106
unknown column is rejected.
107-
- A result expression that is not a direct column, a wildcard, or the scalar
108-
count below — such as another function call, an arithmetic expression, or a
109-
literal — is rejected.
107+
- A result expression that is not a direct column, a wildcard, the scalar count
108+
below, or the cast projection below — such as another function call, an
109+
arithmetic expression, or a literal — is rejected.
110110
- An explicit alias names the result column, so `SELECT id AS author_id`
111111
generates the record component `authorId` while the component's type still
112112
comes from the column and its nullability from the column and its source.
113113
sqlcj itself resolves an alias only in the projection; every other clause
114114
reaches the database as written.
115115

116-
A read may count its matching rows with an aliased `COUNT(*)` as its only
117-
projection:
116+
A read may count its matching rows with an aliased `COUNT(*)`:
118117

119118
```sql
120119
-- name: CountAuthors :one
121120
SELECT COUNT(*) AS total
122121
FROM authors;
122+
123+
-- name: CountAuthorsByCountry :many
124+
SELECT country, COUNT(*) AS total
125+
FROM authors
126+
GROUP BY country
127+
ORDER BY country;
123128
```
124129

125130
- The alias is required and may be written with or without `AS`, so both
@@ -128,16 +133,47 @@ FROM authors;
128133
- The component is a non-null `Long`: the count is typed `BIGINT` because
129134
`count(*)` returns `bigint`, and it is never null because a count is `0` when
130135
no row matches.
136+
- The count is one result column in its own position, so it may stand beside
137+
direct columns, a wildcard, a cast projection, or another count, in either
138+
order. `GROUP BY` itself is not analyzed and reaches the database as written,
139+
so PostgreSQL rather than sqlcj checks that the grouping is valid.
131140
- The count is accepted over any supported source, predicate, ordering, and
132141
pagination shape, and under any annotation.
133142
- `COUNT(*)` without an alias is rejected with
134-
`COUNT(*) requires a result alias, such as COUNT(*) AS total.`, and a
135-
`COUNT(*)` beside another projection item is rejected with
136-
`COUNT(*) must be the only SELECT item.`
143+
`COUNT(*) requires a result alias, such as COUNT(*) AS total.`
137144
- Every other count is still an unsupported result expression, including
138145
`COUNT(column)`, `COUNT(DISTINCT column)`, `COUNT(t.*)`, a qualified
139146
`pg_catalog.count(*)`, and the `FILTER` and `OVER` forms.
140147

148+
A read may also project a computed value by stating its type in a cast:
149+
150+
```sql
151+
-- name: SumRoyaltiesByAuthor :one
152+
SELECT SUM(amount)::numeric AS total
153+
FROM royalties
154+
WHERE author_id = $1;
155+
```
156+
157+
- Both cast spellings are the same projection, so
158+
`SUM(amount)::numeric AS total` and `CAST(SUM(amount) AS numeric) AS total`
159+
are equivalent.
160+
- The alias is required and may be written with or without `AS`, so
161+
`SUM(amount)::numeric total` is equivalent as well. A cast projection without
162+
an alias is rejected with
163+
`A cast projection requires a result alias, such as SUM(amount)::numeric AS total.`
164+
- The result column takes the type the cast states, mapped exactly as a schema
165+
column's declared type is, so a cast may name a declared enum type or a
166+
one-dimensional array such as `::text[]`. A cast type sqlcj does not map is
167+
rejected with
168+
`Result column 'total' has unsupported cast type INTERVAL`, naming the result
169+
column and the declared type.
170+
- The component is always nullable, because the cast operand is not analyzed
171+
and so whether it can read as `NULL` is unknown at compile time.
172+
- The operand itself is not analyzed and reaches the database as written, so
173+
PostgreSQL rather than sqlcj checks its column references and its functions.
174+
A placeholder in a projection is still rejected, including a cast placeholder
175+
such as `$1::int AS x`.
176+
141177
### Predicates
142178

143179
A `WHERE` clause may combine:
@@ -644,12 +680,14 @@ the documented shapes are contract, tested, and safe to rely on.
644680
Rejection stops the run with a diagnostic naming the query source, the query
645681
name, and its header line.
646682

647-
- A result expression that is not a direct column, a wildcard, or an aliased
648-
sole `COUNT(*)`, including another function call, another aggregate, and a
649-
literal. A `COUNT(*)` without an alias and a `COUNT(*)` beside another
650-
projection item each fail with their own diagnostic, while `COUNT(column)`,
651-
`COUNT(DISTINCT column)`, `COUNT(t.*)`, `pg_catalog.count(*)`, and the
652-
`FILTER` and `OVER` forms keep the unsupported-expression rejection.
683+
- A result expression that is not a direct column, a wildcard, an aliased
684+
`COUNT(*)`, or an aliased cast, including another function call, another
685+
aggregate, and a literal. A `COUNT(*)` without an alias, a cast projection
686+
without an alias, and a cast projection whose type sqlcj does not map each
687+
fail with their own diagnostic, while `COUNT(column)`,
688+
`COUNT(DISTINCT column)`, `COUNT(t.*)`, `pg_catalog.count(*)`,
689+
`lower(name) AS n`, and the `FILTER` and `OVER` forms keep the
690+
unsupported-expression rejection.
653691
- A `FROM` item that is not a table, a comma-separated source list, a join that
654692
is not a plain inner or left join, a join predicate that is not one qualified
655693
equality, and a set operation such as `UNION`.
@@ -668,7 +706,8 @@ name, and its header line.
668706
is not the direct operand of its cast, such as `(:x)::int`.
669707
- A cast type sqlcj does not map, such as `$1::interval` or a
670708
multi-dimensional `$1::int[][]`, which is rejected naming the placeholder and
671-
the declared type.
709+
the declared type, or the result column and the declared type when the cast
710+
is a projection such as `id::interval AS i`.
672711
- A `LIKE`-family pattern placeholder that is negated, uses another keyword such
673712
as `SIMILAR TO`, carries an `ESCAPE` clause or a `BINARY` modifier, tests a
674713
non-text column, or stands as the tested value. These restrict the uncast
@@ -809,13 +848,31 @@ Reads:
809848
executes a `LIMIT ... OFFSET ...` page and the same page written as
810849
`OFFSET ... LIMIT ...` against PostgreSQL 16.
811850
- `QueryAnalyzerTest.shouldResolveScalarCountColumnFromItsAlias`,
812-
`QueryAnalyzerTest.shouldResolveScalarCountBesidePredicateParameter`, and
851+
`QueryAnalyzerTest.shouldResolveScalarCountBesidePredicateParameter`,
852+
`QueryAnalyzerTest.shouldResolveScalarCountBesideDirectColumn`,
853+
`QueryAnalyzerTest.shouldResolveScalarCountBeforeDirectColumn`,
854+
`QueryAnalyzerTest.shouldResolveGroupedCountBesidePredicateParameter`, and
813855
`QueryAnalyzerTest.shouldRejectUnsupportedCountProjectionForm` cover the
814-
aliased sole `COUNT(*)`, its focused diagnostics, and the count and function
815-
forms that stay rejected, and
856+
aliased `COUNT(*)` alone and beside a grouped column in either position, its
857+
focused diagnostic, and the count and function forms that stay rejected, and
816858
`PostgresIntegrationTest.shouldExecuteGeneratedScalarCountAgainstPostgres`
817859
compiles a `:one` count into a result record with one `Long` component and
818-
executes it against PostgreSQL 16 for a matching and a non-matching pattern.
860+
executes it against PostgreSQL 16 for a matching and a non-matching pattern,
861+
while
862+
`PostgresIntegrationTest.shouldExecuteGeneratedGroupedCountAgainstPostgres`
863+
executes a `:many` grouped count whose result record carries the grouped
864+
`Boolean` column and the `Long` count in projection order.
865+
- `QueryAnalyzerTest.shouldResolveCastProjectionColumnFromItsAlias`,
866+
`QueryAnalyzerTest.shouldResolveEnumCastProjectionColumn`,
867+
`QueryAnalyzerTest.shouldResolveArrayCastProjectionColumn`,
868+
`QueryAnalyzerTest.shouldRejectUnsupportedCastProjectionForm`, and
869+
`QueryAnalyzerTest.shouldRejectCastPlaceholderProjection` cover both cast
870+
spellings and both alias spellings, the nullable column of the cast type
871+
including a declared enum and an array, the missing alias and the unmapped
872+
cast type, and the projected placeholder that stays rejected, and
873+
`PostgresIntegrationTest.shouldExecuteGeneratedCastProjectionAgainstPostgres`
874+
executes a `:one` cast `SUM` against PostgreSQL 16 whose `BigDecimal`
875+
component is the sum and `null` when no row matches.
819876
- `QueryAnalyzerTest.shouldAnalyzeLeftJoinWithNullableJoinedColumns`,
820877
`QueryAnalyzerTest.shouldExpandAllColumnsOfLeftJoinedSourceAsNullable`,
821878
`QueryAnalyzerTest.shouldExpandQualifiedAllColumnsOfLeftJoinedSourceAsNullable`,

‎sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java‎

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ private QueryModel analyzeSelect(Query query, ParsedSql parsedSql, Select select
288288

289289
List<Source> sources = resolveSources(plainSelect, table, schema);
290290

291-
List<QueryColumn> columns = resolveColumns(plainSelect, sources);
291+
List<QueryColumn> columns = resolveColumns(plainSelect, sources, schema);
292292

293293
Placeholders placeholders = toPlaceholders(parsedSql, schema);
294294

@@ -1730,8 +1730,15 @@ private Placeholders toPlaceholders(ParsedSql parsedSql, Schema schema) {
17301730
* {@code qualifier.*} across one source, each in schema column order. A
17311731
* column of a left-joined source is nullable even when its schema
17321732
* declaration is not, because an unmatched row reads it as {@code NULL}.
1733+
*
1734+
* <p>An aliased scalar count and an aliased cast each resolve to one column
1735+
* in their own position, so either may stand beside any other item.
17331736
*/
1734-
private List<QueryColumn> resolveColumns(PlainSelect plainSelect, List<Source> sources) {
1737+
private List<QueryColumn> resolveColumns(
1738+
PlainSelect plainSelect,
1739+
List<Source> sources,
1740+
Schema schema
1741+
) {
17351742
List<QueryColumn> columns = new ArrayList<>();
17361743

17371744
for (SelectItem<?> selectItem : plainSelect.getSelectItems()) {
@@ -1776,12 +1783,13 @@ private List<QueryColumn> resolveColumns(PlainSelect plainSelect, List<Source> s
17761783
}
17771784

17781785
if (expression instanceof Function function && isScalarCount(function)) {
1779-
columns.add(
1780-
scalarCountColumn(
1781-
selectItem.getAlias(),
1782-
plainSelect.getSelectItems().size()
1783-
)
1784-
);
1786+
columns.add(scalarCountColumn(selectItem.getAlias()));
1787+
1788+
continue;
1789+
}
1790+
1791+
if (expression instanceof CastExpression cast && cast.getColDataType() != null) {
1792+
columns.add(castColumn(cast, selectItem.getAlias(), schema));
17851793

17861794
continue;
17871795
}
@@ -1826,20 +1834,47 @@ private boolean isScalarCount(Function function) {
18261834
* {@code count(*)} returns {@code bigint}, and it is non-null because a
18271835
* count is always a number, {@code 0} when no row matches.
18281836
*/
1829-
private QueryColumn scalarCountColumn(Alias alias, int selectItemCount) {
1830-
if (selectItemCount > 1) {
1837+
private QueryColumn scalarCountColumn(Alias alias) {
1838+
if (alias == null) {
18311839
throw new UnsupportedOperationException(
1832-
"COUNT(*) must be the only SELECT item."
1840+
"COUNT(*) requires a result alias, such as COUNT(*) AS total."
18331841
);
18341842
}
18351843

1844+
return new QueryColumn(alias.getUnquotedName(), ColumnType.BIGINT, false);
1845+
}
1846+
1847+
/**
1848+
* Resolves a cast projection into its single result column, which is named
1849+
* after the required alias and typed by the cast, as a cast types a
1850+
* placeholder. The column is nullable because the cast operand is not
1851+
* analyzed, so whether it can read as {@code NULL} is unknown here.
1852+
*
1853+
* <p>The operand itself reaches the database as written, as a non-binding
1854+
* write value does, so PostgreSQL rather than sqlcj checks it.
1855+
*/
1856+
private QueryColumn castColumn(CastExpression cast, Alias alias, Schema schema) {
18361857
if (alias == null) {
18371858
throw new UnsupportedOperationException(
1838-
"COUNT(*) requires a result alias, such as COUNT(*) AS total."
1859+
"A cast projection requires a result alias, such as SUM(amount)::numeric AS total."
18391860
);
18401861
}
18411862

1842-
return new QueryColumn(alias.getUnquotedName(), ColumnType.BIGINT, false);
1863+
String name = alias.getUnquotedName();
1864+
1865+
ColumnTypeMapping.MappedType type = columnTypeMapping.map(
1866+
cast.getColDataType(),
1867+
schema.enums()
1868+
);
1869+
1870+
if (!type.mapped()) {
1871+
throw new UnsupportedOperationException(
1872+
"Result column '%s' has unsupported cast type %s"
1873+
.formatted(name, type.unsupportedType())
1874+
);
1875+
}
1876+
1877+
return new QueryColumn(name, type.type(), true, type.enumType(), type.array());
18431878
}
18441879

18451880
/**

0 commit comments

Comments
 (0)