From 51ced2bcc526e1c98ccf78d0e6d8523a61cdd408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sat, 3 Oct 2026 18:38:53 +0300 Subject: [PATCH 1/2] feat(analysis): allow grouped counts and cast-typed projections An aliased COUNT(*) may now stand beside other SELECT items as a non-null BIGINT column in its own position. A projection written as ::type AS alias, or CAST( AS type) AS alias, becomes a nullable result column of the cast type, including declared enums and one-dimensional arrays. The cast operand is not analyzed and reaches the database as written; an unaliased cast and an unmapped cast type are rejected with their own diagnostics, and a projected placeholder stays rejected. --- .../dev/sqlcj/analysis/QueryAnalyzer.java | 61 ++++-- .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 204 +++++++++++++++++- .../compiler/PostgresIntegrationTest.java | 94 ++++++++ 3 files changed, 342 insertions(+), 17 deletions(-) diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java index 0401560..3eb3dbe 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -288,7 +288,7 @@ private QueryModel analyzeSelect(Query query, ParsedSql parsedSql, Select select List sources = resolveSources(plainSelect, table, schema); - List columns = resolveColumns(plainSelect, sources); + List columns = resolveColumns(plainSelect, sources, schema); Placeholders placeholders = toPlaceholders(parsedSql, schema); @@ -1730,8 +1730,15 @@ private Placeholders toPlaceholders(ParsedSql parsedSql, Schema schema) { * {@code qualifier.*} across one source, each in schema column order. A * column of a left-joined source is nullable even when its schema * declaration is not, because an unmatched row reads it as {@code NULL}. + * + *

An aliased scalar count and an aliased cast each resolve to one column + * in their own position, so either may stand beside any other item. */ - private List resolveColumns(PlainSelect plainSelect, List sources) { + private List resolveColumns( + PlainSelect plainSelect, + List sources, + Schema schema + ) { List columns = new ArrayList<>(); for (SelectItem selectItem : plainSelect.getSelectItems()) { @@ -1776,12 +1783,13 @@ private List resolveColumns(PlainSelect plainSelect, List s } if (expression instanceof Function function && isScalarCount(function)) { - columns.add( - scalarCountColumn( - selectItem.getAlias(), - plainSelect.getSelectItems().size() - ) - ); + columns.add(scalarCountColumn(selectItem.getAlias())); + + continue; + } + + if (expression instanceof CastExpression cast && cast.getColDataType() != null) { + columns.add(castColumn(cast, selectItem.getAlias(), schema)); continue; } @@ -1826,20 +1834,47 @@ private boolean isScalarCount(Function function) { * {@code count(*)} returns {@code bigint}, and it is non-null because a * count is always a number, {@code 0} when no row matches. */ - private QueryColumn scalarCountColumn(Alias alias, int selectItemCount) { - if (selectItemCount > 1) { + private QueryColumn scalarCountColumn(Alias alias) { + if (alias == null) { throw new UnsupportedOperationException( - "COUNT(*) must be the only SELECT item." + "COUNT(*) requires a result alias, such as COUNT(*) AS total." ); } + return new QueryColumn(alias.getUnquotedName(), ColumnType.BIGINT, false); + } + + /** + * Resolves a cast projection into its single result column, which is named + * after the required alias and typed by the cast, as a cast types a + * placeholder. The column is nullable because the cast operand is not + * analyzed, so whether it can read as {@code NULL} is unknown here. + * + *

The operand itself reaches the database as written, as a non-binding + * write value does, so PostgreSQL rather than sqlcj checks it. + */ + private QueryColumn castColumn(CastExpression cast, Alias alias, Schema schema) { if (alias == null) { throw new UnsupportedOperationException( - "COUNT(*) requires a result alias, such as COUNT(*) AS total." + "A cast projection requires a result alias, such as SUM(amount)::numeric AS total." ); } - return new QueryColumn(alias.getUnquotedName(), ColumnType.BIGINT, false); + String name = alias.getUnquotedName(); + + ColumnTypeMapping.MappedType type = columnTypeMapping.map( + cast.getColDataType(), + schema.enums() + ); + + if (!type.mapped()) { + throw new UnsupportedOperationException( + "Result column '%s' has unsupported cast type %s" + .formatted(name, type.unsupportedType()) + ); + } + + return new QueryColumn(name, type.type(), true, type.enumType(), type.array()); } /** diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java index 7c958ac..0b0f115 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -1378,6 +1378,204 @@ void shouldResolveScalarCountBesidePredicateParameter() { ); } + /** + * A grouped count stands beside a direct column and keeps its own + * position, while the grouped result is no longer one complete table row. + */ + @Test + void shouldResolveScalarCountBesideDirectColumn() { + String sql = "SELECT name, COUNT(*) AS n FROM users GROUP BY name"; + + QueryModel model = analyzer.analyze( + new Query("CountUsersByName", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of( + new QueryColumn("name", ColumnType.VARCHAR, true), + new QueryColumn("n", ColumnType.BIGINT, false) + ), + model.columns() + ); + + assertTrue(model.parameters().isEmpty()); + assertNull(model.rowTable()); + } + + /** A count written before the grouped column keeps that position. */ + @Test + void shouldResolveScalarCountBeforeDirectColumn() { + String sql = "SELECT COUNT(*) AS n, name FROM users GROUP BY name"; + + QueryModel model = analyzer.analyze( + new Query("CountUsersByName", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of( + new QueryColumn("n", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true) + ), + model.columns() + ); + } + + /** A grouped count keeps the parameters of its predicate. */ + @Test + void shouldResolveGroupedCountBesidePredicateParameter() { + String sql = "SELECT name, COUNT(*) AS n FROM users WHERE active = $1 GROUP BY name"; + + QueryModel model = analyzer.analyze( + new Query("CountActiveUsersByName", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of( + new QueryColumn("name", ColumnType.VARCHAR, true), + new QueryColumn("n", ColumnType.BIGINT, false) + ), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "active", ColumnType.BOOLEAN)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + } + + /** + * An aliased cast projection is a nullable result column of the cast type, + * in either cast spelling and with or without {@code AS}. Its operand is + * not analyzed and reaches the database as written. + */ + @ParameterizedTest + @ValueSource( + strings = { + "SELECT SUM(id)::numeric AS total FROM users", + "SELECT CAST(SUM(id) AS numeric) AS total FROM users", + "SELECT SUM(id)::numeric total FROM users" + } + ) + void shouldResolveCastProjectionColumnFromItsAlias(String sql) { + QueryModel model = analyzer.analyze( + new Query("SumUserIds", QueryType.ONE, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryColumn("total", ColumnType.DECIMAL, true)), + model.columns() + ); + + assertTrue(model.parameters().isEmpty()); + assertNull(model.rowTable()); + } + + /** A cast projection may name a declared enum type. */ + @Test + void shouldResolveEnumCastProjectionColumn() { + String sql = "SELECT handle::stage_setting AS s FROM stages"; + + QueryModel model = analyzer.analyze( + new Query("ListStageSettings", QueryType.MANY, sql), + parser.parse(sql), + enumSchema + ); + + assertEquals( + List.of(new QueryColumn("s", ColumnType.ENUM, true, "stage_setting")), + model.columns() + ); + } + + /** A cast projection of a one-dimensional array is an array column. */ + @Test + void shouldResolveArrayCastProjectionColumn() { + String sql = "SELECT handle::text[] AS labels FROM stages"; + + QueryModel model = analyzer.analyze( + new Query("ListStageLabels", QueryType.MANY, sql), + parser.parse(sql), + arraySchema + ); + + assertEquals( + List.of(new QueryColumn("labels", ColumnType.TEXT, true, null, true)), + model.columns() + ); + } + + /** + * A cast projection without an alias names no result column, and a cast + * type sqlcj does not map is rejected naming the result column and the + * declared type. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "SELECT SUM(id)::numeric FROM users" + + "|A cast projection requires a result alias," + + " such as SUM(amount)::numeric AS total.", + "SELECT CAST(SUM(id) AS numeric) FROM users" + + "|A cast projection requires a result alias," + + " such as SUM(amount)::numeric AS total.", + "SELECT id::interval AS i FROM users" + + "|Result column 'i' has unsupported cast type INTERVAL" + } + ) + void shouldRejectUnsupportedCastProjectionForm(String sql, String message) { + Query query = new Query("SumUserIds", QueryType.ONE, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, schema) + ); + + assertEquals(message, exception.getMessage()); + } + + /** + * A cast does not make a projected placeholder analyzable, so it stays + * rejected by the placeholder accounting in either spelling. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "SELECT $1::int AS x FROM users|[1]", + "SELECT :n::int AS x FROM users|[:n]" + } + ) + void shouldRejectCastPlaceholderProjection(String sql, String compiled) { + Query query = new Query("ListUsers", QueryType.MANY, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, schema) + ); + + assertEquals( + "SQL placeholders %s are not the analyzed parameters []; " + .formatted(compiled) + + "a placeholder is in an unsupported location", + exception.getMessage() + ); + } + @ParameterizedTest @CsvSource( delimiter = '|', @@ -1385,10 +1583,8 @@ void shouldResolveScalarCountBesidePredicateParameter() { value = { "SELECT COUNT(*) FROM users" + "|COUNT(*) requires a result alias, such as COUNT(*) AS total.", - "SELECT COUNT(*) AS n, id FROM users" - + "|COUNT(*) must be the only SELECT item.", - "SELECT id, COUNT(*) AS n FROM users" - + "|COUNT(*) must be the only SELECT item.", + "SELECT name, COUNT(*) FROM users GROUP BY name" + + "|COUNT(*) requires a result alias, such as COUNT(*) AS total.", "SELECT COUNT(id) AS n FROM users" + "|Unsupported SELECT expression: Function", "SELECT COUNT(DISTINCT id) AS n FROM users" diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java index 2f21c17..797dbf0 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -972,6 +972,100 @@ INSERT INTO users (id, code, name) } } + /** + * Covers a grouped count end to end: a {@code :many} read whose projection + * is a direct column beside an aliased {@code COUNT(*)} is generated, + * compiled, and executed against PostgreSQL, so its result record exposes + * the grouped column and the count in projection order. + */ + @Test + void shouldExecuteGeneratedGroupedCountAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: CountUsersByActive :many + SELECT active, COUNT(*) AS users + FROM users + GROUP BY active + ORDER BY active; + """); + + execute(""" + INSERT INTO users (id, code, name, active) + VALUES + (1, 1, 'Alice', TRUE), + (2, 2, 'Amy', TRUE), + (3, 3, 'Bob', FALSE) + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Object result = repository + .getClass() + .getMethod("countUsersByActive") + .invoke(repository); + + List groups = (List) result; + + assertEquals(2, groups.size()); + + assertEquals( + List.of("active", "users"), + recordComponentNames(groups.getFirst()) + ); + + assertEquals( + List.of(Boolean.class, Long.class), + recordComponentTypes(groups.getFirst()) + ); + + assertEquals(Boolean.FALSE, component(groups.get(0), "active")); + assertEquals(1L, component(groups.get(0), "users")); + assertEquals(Boolean.TRUE, component(groups.get(1), "active")); + assertEquals(2L, component(groups.get(1), "users")); + } + } + + /** + * Covers a cast projection end to end: a {@code :one} read whose single + * projection is a cast aggregate is generated, compiled, and executed + * against PostgreSQL, so its result record exposes one nullable + * {@code BigDecimal} component that is {@code null} when no row matches. + */ + @Test + void shouldExecuteGeneratedCastProjectionAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: SumBalanceByName :one + SELECT SUM(balance)::numeric AS total + FROM users + WHERE name LIKE $1; + """); + + execute(""" + INSERT INTO users (id, code, name, balance) + VALUES + (1, 1, 'Alice', 10.50), + (2, 2, 'Amy', 2.25), + (3, 3, 'Bob', 100.00) + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Method sum = repository.getClass().getMethod( + "sumBalanceByName", + String.class + ); + + Object matching = sum.invoke(repository, "A%"); + + assertEquals(List.of("total"), recordComponentNames(matching)); + assertEquals(List.of(BigDecimal.class), recordComponentTypes(matching)); + assertEquals(new BigDecimal("12.75"), component(matching, "total")); + + assertNull(component(sum.invoke(repository, "Z%"), "total")); + } + } + @Test void shouldExecuteGeneratedWriteAgainstPostgres() throws Exception { Path classesDirectory = generateAndCompile(""" From a2ab4649afd79a375d3bfed0dddf6a24381b2c00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sat, 3 Oct 2026 18:38:57 +0300 Subject: [PATCH 2/2] docs: document grouped counts and cast projections --- docs/queries.md | 95 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 76 insertions(+), 19 deletions(-) diff --git a/docs/queries.md b/docs/queries.md index bde4924..e5bd771 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -104,22 +104,27 @@ configuration entry. positional row mapper. - An unqualified column must be found in exactly one source; an ambiguous or unknown column is rejected. -- A result expression that is not a direct column, a wildcard, or the scalar - count below — such as another function call, an arithmetic expression, or a - literal — is rejected. +- A result expression that is not a direct column, a wildcard, the scalar count + below, or the cast projection below — such as another function call, an + arithmetic expression, or a literal — is rejected. - An explicit alias names the result column, so `SELECT id AS author_id` generates the record component `authorId` while the component's type still comes from the column and its nullability from the column and its source. sqlcj itself resolves an alias only in the projection; every other clause reaches the database as written. -A read may count its matching rows with an aliased `COUNT(*)` as its only -projection: +A read may count its matching rows with an aliased `COUNT(*)`: ```sql -- name: CountAuthors :one SELECT COUNT(*) AS total FROM authors; + +-- name: CountAuthorsByCountry :many +SELECT country, COUNT(*) AS total +FROM authors +GROUP BY country +ORDER BY country; ``` - The alias is required and may be written with or without `AS`, so both @@ -128,16 +133,47 @@ FROM authors; - The component is a non-null `Long`: the count is typed `BIGINT` because `count(*)` returns `bigint`, and it is never null because a count is `0` when no row matches. +- The count is one result column in its own position, so it may stand beside + direct columns, a wildcard, a cast projection, or another count, in either + order. `GROUP BY` itself is not analyzed and reaches the database as written, + so PostgreSQL rather than sqlcj checks that the grouping is valid. - The count is accepted over any supported source, predicate, ordering, and pagination shape, and under any annotation. - `COUNT(*)` without an alias is rejected with - `COUNT(*) requires a result alias, such as COUNT(*) AS total.`, and a - `COUNT(*)` beside another projection item is rejected with - `COUNT(*) must be the only SELECT item.` + `COUNT(*) requires a result alias, such as COUNT(*) AS total.` - Every other count is still an unsupported result expression, including `COUNT(column)`, `COUNT(DISTINCT column)`, `COUNT(t.*)`, a qualified `pg_catalog.count(*)`, and the `FILTER` and `OVER` forms. +A read may also project a computed value by stating its type in a cast: + +```sql +-- name: SumRoyaltiesByAuthor :one +SELECT SUM(amount)::numeric AS total +FROM royalties +WHERE author_id = $1; +``` + +- Both cast spellings are the same projection, so + `SUM(amount)::numeric AS total` and `CAST(SUM(amount) AS numeric) AS total` + are equivalent. +- The alias is required and may be written with or without `AS`, so + `SUM(amount)::numeric total` is equivalent as well. A cast projection without + an alias is rejected with + `A cast projection requires a result alias, such as SUM(amount)::numeric AS total.` +- The result column takes the type the cast states, mapped exactly as a schema + column's declared type is, so a cast may name a declared enum type or a + one-dimensional array such as `::text[]`. A cast type sqlcj does not map is + rejected with + `Result column 'total' has unsupported cast type INTERVAL`, naming the result + column and the declared type. +- The component is always nullable, because the cast operand is not analyzed + and so whether it can read as `NULL` is unknown at compile time. +- The operand itself is not analyzed and reaches the database as written, so + PostgreSQL rather than sqlcj checks its column references and its functions. + A placeholder in a projection is still rejected, including a cast placeholder + such as `$1::int AS x`. + ### Predicates A `WHERE` clause may combine: @@ -644,12 +680,14 @@ the documented shapes are contract, tested, and safe to rely on. Rejection stops the run with a diagnostic naming the query source, the query name, and its header line. -- A result expression that is not a direct column, a wildcard, or an aliased - sole `COUNT(*)`, including another function call, another aggregate, and a - literal. A `COUNT(*)` without an alias and a `COUNT(*)` beside another - projection item each fail with their own diagnostic, while `COUNT(column)`, - `COUNT(DISTINCT column)`, `COUNT(t.*)`, `pg_catalog.count(*)`, and the - `FILTER` and `OVER` forms keep the unsupported-expression rejection. +- A result expression that is not a direct column, a wildcard, an aliased + `COUNT(*)`, or an aliased cast, including another function call, another + aggregate, and a literal. A `COUNT(*)` without an alias, a cast projection + without an alias, and a cast projection whose type sqlcj does not map each + fail with their own diagnostic, while `COUNT(column)`, + `COUNT(DISTINCT column)`, `COUNT(t.*)`, `pg_catalog.count(*)`, + `lower(name) AS n`, and the `FILTER` and `OVER` forms keep the + unsupported-expression rejection. - A `FROM` item that is not a table, a comma-separated source list, a join that is not a plain inner or left join, a join predicate that is not one qualified equality, and a set operation such as `UNION`. @@ -668,7 +706,8 @@ name, and its header line. is not the direct operand of its cast, such as `(:x)::int`. - A cast type sqlcj does not map, such as `$1::interval` or a multi-dimensional `$1::int[][]`, which is rejected naming the placeholder and - the declared type. + the declared type, or the result column and the declared type when the cast + is a projection such as `id::interval AS i`. - A `LIKE`-family pattern placeholder that is negated, uses another keyword such as `SIMILAR TO`, carries an `ESCAPE` clause or a `BINARY` modifier, tests a non-text column, or stands as the tested value. These restrict the uncast @@ -809,13 +848,31 @@ Reads: executes a `LIMIT ... OFFSET ...` page and the same page written as `OFFSET ... LIMIT ...` against PostgreSQL 16. - `QueryAnalyzerTest.shouldResolveScalarCountColumnFromItsAlias`, - `QueryAnalyzerTest.shouldResolveScalarCountBesidePredicateParameter`, and + `QueryAnalyzerTest.shouldResolveScalarCountBesidePredicateParameter`, + `QueryAnalyzerTest.shouldResolveScalarCountBesideDirectColumn`, + `QueryAnalyzerTest.shouldResolveScalarCountBeforeDirectColumn`, + `QueryAnalyzerTest.shouldResolveGroupedCountBesidePredicateParameter`, and `QueryAnalyzerTest.shouldRejectUnsupportedCountProjectionForm` cover the - aliased sole `COUNT(*)`, its focused diagnostics, and the count and function - forms that stay rejected, and + aliased `COUNT(*)` alone and beside a grouped column in either position, its + focused diagnostic, and the count and function forms that stay rejected, and `PostgresIntegrationTest.shouldExecuteGeneratedScalarCountAgainstPostgres` compiles a `:one` count into a result record with one `Long` component and - executes it against PostgreSQL 16 for a matching and a non-matching pattern. + executes it against PostgreSQL 16 for a matching and a non-matching pattern, + while + `PostgresIntegrationTest.shouldExecuteGeneratedGroupedCountAgainstPostgres` + executes a `:many` grouped count whose result record carries the grouped + `Boolean` column and the `Long` count in projection order. +- `QueryAnalyzerTest.shouldResolveCastProjectionColumnFromItsAlias`, + `QueryAnalyzerTest.shouldResolveEnumCastProjectionColumn`, + `QueryAnalyzerTest.shouldResolveArrayCastProjectionColumn`, + `QueryAnalyzerTest.shouldRejectUnsupportedCastProjectionForm`, and + `QueryAnalyzerTest.shouldRejectCastPlaceholderProjection` cover both cast + spellings and both alias spellings, the nullable column of the cast type + including a declared enum and an array, the missing alias and the unmapped + cast type, and the projected placeholder that stays rejected, and + `PostgresIntegrationTest.shouldExecuteGeneratedCastProjectionAgainstPostgres` + executes a `:one` cast `SUM` against PostgreSQL 16 whose `BigDecimal` + component is the sum and `null` when no row matches. - `QueryAnalyzerTest.shouldAnalyzeLeftJoinWithNullableJoinedColumns`, `QueryAnalyzerTest.shouldExpandAllColumnsOfLeftJoinedSourceAsNullable`, `QueryAnalyzerTest.shouldExpandQualifiedAllColumnsOfLeftJoinedSourceAsNullable`,