From 76f059d88b4acf03a75c281f6999e27c2f5c9f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Fri, 25 Sep 2026 19:41:47 +0300 Subject: [PATCH 1/2] feat(analysis): accept an aliased sole COUNT(*) projection --- .../dev/sqlcj/analysis/QueryAnalyzer.java | 59 ++++++++++++++ .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 79 +++++++++++++++++++ .../compiler/PostgresIntegrationTest.java | 46 +++++++++++ 3 files changed, 184 insertions(+) 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 0796087..f71c39f 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -9,6 +9,7 @@ import dev.sqlcj.type.TypeResolver; import net.sf.jsqlparser.expression.Alias; import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.Function; import net.sf.jsqlparser.expression.JdbcNamedParameter; import net.sf.jsqlparser.expression.JdbcParameter; import net.sf.jsqlparser.expression.operators.conditional.AndExpression; @@ -1240,6 +1241,17 @@ private List resolveColumns(PlainSelect plainSelect, List s continue; } + if (expression instanceof Function function && isScalarCount(function)) { + columns.add( + scalarCountColumn( + selectItem.getAlias(), + plainSelect.getSelectItems().size() + ) + ); + + continue; + } + throw new UnsupportedOperationException( "Unsupported SELECT expression: " + expression.getClass().getSimpleName() @@ -1249,6 +1261,53 @@ private List resolveColumns(PlainSelect plainSelect, List s return columns; } + /** + * Reports whether a projected function is the supported scalar count, which + * is an unqualified case-insensitive {@code COUNT} whose single argument is + * exactly {@code *}. A qualified name such as {@code pg_catalog.count(*)}, + * a {@code DISTINCT} count, and {@code COUNT(t.*)}, whose argument is a + * subtype of the {@code COUNT(*)} argument, are other functions. + */ + private boolean isScalarCount(Function function) { + List name = function.getMultipartName(); + + if (name == null || name.size() != 1 || !name.getFirst().equalsIgnoreCase("count")) { + return false; + } + + if (function.isDistinct()) { + return false; + } + + ExpressionList arguments = function.getParameters(); + + return arguments != null + && arguments.size() == 1 + && arguments.getFirst().getClass() == AllColumns.class; + } + + /** + * Resolves the scalar count projection into its single result column, which + * is named after the required alias. The column is {@code BIGINT} because + * {@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) { + throw new UnsupportedOperationException( + "COUNT(*) must be the only SELECT item." + ); + } + + if (alias == null) { + throw new UnsupportedOperationException( + "COUNT(*) requires a result alias, such as COUNT(*) AS total." + ); + } + + return new QueryColumn(alias.getUnquotedName(), ColumnType.BIGINT, false); + } + /** * Reports the table whose complete row a read returns, which is the single * query source when the projection is exactly {@code *} or 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 3a2cdcd..72e55f2 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -1232,6 +1232,85 @@ void shouldRejectPlaceholderInUnsupportedPaginationValue(String sql) { ); } + @Test + void shouldResolveScalarCountColumnFromItsAlias() { + String sql = "SELECT COUNT(*) AS total FROM users"; + + QueryModel model = analyzer.analyze( + new Query("CountUsers", QueryType.ONE, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryColumn("total", ColumnType.BIGINT, false)), + model.columns() + ); + + assertTrue(model.parameters().isEmpty()); + assertNull(model.rowTable()); + } + + @Test + void shouldResolveScalarCountBesidePredicateParameter() { + String sql = "SELECT count(*) user_count FROM users WHERE name LIKE $1"; + + QueryModel model = analyzer.analyze( + new Query("CountMatchingUsers", QueryType.ONE, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryColumn("user_count", ColumnType.BIGINT, false)), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "name", ColumnType.VARCHAR)), + model.parameters() + ); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + 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 COUNT(id) AS n FROM users" + + "|Unsupported SELECT expression: Function", + "SELECT COUNT(DISTINCT id) AS n FROM users" + + "|Unsupported SELECT expression: Function", + "SELECT COUNT(u.*) AS n FROM users u" + + "|Unsupported SELECT expression: Function", + "SELECT pg_catalog.count(*) AS n FROM users" + + "|Unsupported SELECT expression: Function", + "SELECT lower(name) AS n FROM users" + + "|Unsupported SELECT expression: Function", + "SELECT COUNT(*) FILTER (WHERE id > 1) AS n FROM users" + + "|Unsupported SELECT expression: AnalyticExpression", + "SELECT COUNT(*) OVER () AS n FROM users" + + "|Unsupported SELECT expression: AnalyticExpression" + } + ) + void shouldRejectUnsupportedCountProjectionForm(String sql, String message) { + Query query = new Query("CountUsers", QueryType.ONE, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, schema) + ); + + assertEquals(message, exception.getMessage()); + } + @Test void shouldResolveQueryParametersInsideInExpression() { Query query = new Query( 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 358ce2a..d5d5d07 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -561,6 +561,52 @@ INSERT INTO users (id, code, name) } } + /** + * Covers the scalar count end to end: a {@code :one} count read is + * generated, compiled, and executed against PostgreSQL, so its result + * record exposes one {@code Long} component that counts the matching rows + * and is {@code 0} when none match. + */ + @Test + void shouldExecuteGeneratedScalarCountAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: CountUsersByName :one + SELECT COUNT(*) AS total + FROM users + WHERE name LIKE $1; + """); + + execute(""" + INSERT INTO users (id, code, name) + VALUES + (1, 1, 'Alice'), + (2, 2, 'Amy'), + (3, 3, 'Bob') + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Method count = repository.getClass().getMethod( + "countUsersByName", + String.class + ); + + Object matching = count.invoke(repository, "A%"); + + assertEquals(List.of("total"), recordComponentNames(matching)); + + assertEquals( + Long.class, + matching.getClass().getRecordComponents()[0].getType() + ); + + assertEquals(2L, component(matching, "total")); + + assertEquals(0L, component(count.invoke(repository, "Z%"), "total")); + } + } + @Test void shouldExecuteGeneratedWriteAgainstPostgres() throws Exception { Path classesDirectory = generateAndCompile(""" From bd880c6058a6a090e38ec35280b76c7a4896ad24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Fri, 25 Sep 2026 19:41:58 +0300 Subject: [PATCH 2/2] docs: document the aliased COUNT(*) scalar count --- docs/queries.md | 46 ++++++++++++++++++++++++++++++++++++++++++---- docs/quickstart.md | 2 +- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/queries.md b/docs/queries.md index a3055dd..0d853eb 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -99,13 +99,39 @@ 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 or a wildcard, such as a - function call, an arithmetic expression, or a literal, 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. - An explicit alias names the result column, so `SELECT id AS author_id` generates the record component `authorId` while the component's type and nullability still come from the column. 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: + +```sql +-- name: CountAuthors :one +SELECT COUNT(*) AS total +FROM authors; +``` + +- The alias is required and may be written with or without `AS`, so both + `COUNT(*) AS total` and `COUNT(*) total` generate the result record + `CountAuthorsResult` with the single component `total`. +- 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 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.` +- 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. + ### Predicates A `WHERE` clause may combine: @@ -440,8 +466,12 @@ 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 or a wildcard, including a - function call, an aggregate, and a literal. +- 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 `FROM` item that is not a table, a comma-separated source list, a join that is not a plain inner join, a join predicate that is not one qualified equality, and a set operation such as `UNION`. @@ -588,6 +618,14 @@ Reads: `PostgresIntegrationTest.shouldExecuteGeneratedPaginationAgainstPostgres` executes a `LIMIT ... OFFSET ...` page and the same page written as `OFFSET ... LIMIT ...` against PostgreSQL 16. +- `QueryAnalyzerTest.shouldResolveScalarCountColumnFromItsAlias`, + `QueryAnalyzerTest.shouldResolveScalarCountBesidePredicateParameter`, and + `QueryAnalyzerTest.shouldRejectUnsupportedCountProjectionForm` cover the + aliased sole `COUNT(*)`, its focused diagnostics, 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. - `QueryAnalyzerTest.shouldResolveRowTableForFullRowSelect`, `QueryAnalyzerTest.shouldNotResolveRowTableForQuerySpecificResult`, `QueryAnalyzerTest.shouldNotResolveRowTableForJoinedWildcard`, and diff --git a/docs/quickstart.md b/docs/quickstart.md index d258d05..f492082 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -416,7 +416,7 @@ If a query is invalid, `sqlcj generate` prints one diagnostic naming the source, the query, and the line, and exits with status `1`: ```text -sqlcj: Invalid query 'CountAuthors' in /home/dev/my-app/sql/queries.sql at line 26: Unsupported SELECT expression: Function +sqlcj: Invalid query 'CountAuthorBios' in /home/dev/my-app/sql/queries.sql at line 31: Unsupported SELECT expression: Function ``` Every configured source is compiled before any file is written, so a diagnostic