Skip to content

Commit bd1233d

Browse files
authored
Merge pull request #32 from sqlcj-dev/feature/scalar-count-projection
feat(analysis): accept an aliased sole COUNT(*) projection
2 parents 0c55aa4 + bd880c6 commit bd1233d

5 files changed

Lines changed: 227 additions & 5 deletions

File tree

‎docs/queries.md‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,39 @@ configuration entry.
9999
positional row mapper.
100100
- An unqualified column must be found in exactly one source; an ambiguous or
101101
unknown column is rejected.
102-
- A result expression that is not a direct column or a wildcard, such as a
103-
function call, an arithmetic expression, or a literal, is rejected.
102+
- A result expression that is not a direct column, a wildcard, or the scalar
103+
count below — such as another function call, an arithmetic expression, or a
104+
literal — is rejected.
104105
- An explicit alias names the result column, so `SELECT id AS author_id`
105106
generates the record component `authorId` while the component's type and
106107
nullability still come from the column. sqlcj itself resolves an alias only in
107108
the projection; every other clause reaches the database as written.
108109

110+
A read may count its matching rows with an aliased `COUNT(*)` as its only
111+
projection:
112+
113+
```sql
114+
-- name: CountAuthors :one
115+
SELECT COUNT(*) AS total
116+
FROM authors;
117+
```
118+
119+
- The alias is required and may be written with or without `AS`, so both
120+
`COUNT(*) AS total` and `COUNT(*) total` generate the result record
121+
`CountAuthorsResult` with the single component `total`.
122+
- The component is a non-null `Long`: the count is typed `BIGINT` because
123+
`count(*)` returns `bigint`, and it is never null because a count is `0` when
124+
no row matches.
125+
- The count is accepted over any supported source, predicate, ordering, and
126+
pagination shape, and under any annotation.
127+
- `COUNT(*)` without an alias is rejected with
128+
`COUNT(*) requires a result alias, such as COUNT(*) AS total.`, and a
129+
`COUNT(*)` beside another projection item is rejected with
130+
`COUNT(*) must be the only SELECT item.`
131+
- Every other count is still an unsupported result expression, including
132+
`COUNT(column)`, `COUNT(DISTINCT column)`, `COUNT(t.*)`, a qualified
133+
`pg_catalog.count(*)`, and the `FILTER` and `OVER` forms.
134+
109135
### Predicates
110136

111137
A `WHERE` clause may combine:
@@ -440,8 +466,12 @@ the documented shapes are contract, tested, and safe to rely on.
440466
Rejection stops the run with a diagnostic naming the query source, the query
441467
name, and its header line.
442468

443-
- A result expression that is not a direct column or a wildcard, including a
444-
function call, an aggregate, and a literal.
469+
- A result expression that is not a direct column, a wildcard, or an aliased
470+
sole `COUNT(*)`, including another function call, another aggregate, and a
471+
literal. A `COUNT(*)` without an alias and a `COUNT(*)` beside another
472+
projection item each fail with their own diagnostic, while `COUNT(column)`,
473+
`COUNT(DISTINCT column)`, `COUNT(t.*)`, `pg_catalog.count(*)`, and the
474+
`FILTER` and `OVER` forms keep the unsupported-expression rejection.
445475
- A `FROM` item that is not a table, a comma-separated source list, a join that
446476
is not a plain inner join, a join predicate that is not one qualified
447477
equality, and a set operation such as `UNION`.
@@ -588,6 +618,14 @@ Reads:
588618
`PostgresIntegrationTest.shouldExecuteGeneratedPaginationAgainstPostgres`
589619
executes a `LIMIT ... OFFSET ...` page and the same page written as
590620
`OFFSET ... LIMIT ...` against PostgreSQL 16.
621+
- `QueryAnalyzerTest.shouldResolveScalarCountColumnFromItsAlias`,
622+
`QueryAnalyzerTest.shouldResolveScalarCountBesidePredicateParameter`, and
623+
`QueryAnalyzerTest.shouldRejectUnsupportedCountProjectionForm` cover the
624+
aliased sole `COUNT(*)`, its focused diagnostics, and the count and function
625+
forms that stay rejected, and
626+
`PostgresIntegrationTest.shouldExecuteGeneratedScalarCountAgainstPostgres`
627+
compiles a `:one` count into a result record with one `Long` component and
628+
executes it against PostgreSQL 16 for a matching and a non-matching pattern.
591629
- `QueryAnalyzerTest.shouldResolveRowTableForFullRowSelect`,
592630
`QueryAnalyzerTest.shouldNotResolveRowTableForQuerySpecificResult`,
593631
`QueryAnalyzerTest.shouldNotResolveRowTableForJoinedWildcard`, and

‎docs/quickstart.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ If a query is invalid, `sqlcj generate` prints one diagnostic naming the source,
416416
the query, and the line, and exits with status `1`:
417417

418418
```text
419-
sqlcj: Invalid query 'CountAuthors' in /home/dev/my-app/sql/queries.sql at line 26: Unsupported SELECT expression: Function
419+
sqlcj: Invalid query 'CountAuthorBios' in /home/dev/my-app/sql/queries.sql at line 31: Unsupported SELECT expression: Function
420420
```
421421

422422
Every configured source is compiled before any file is written, so a diagnostic

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import dev.sqlcj.type.TypeResolver;
1010
import net.sf.jsqlparser.expression.Alias;
1111
import net.sf.jsqlparser.expression.Expression;
12+
import net.sf.jsqlparser.expression.Function;
1213
import net.sf.jsqlparser.expression.JdbcNamedParameter;
1314
import net.sf.jsqlparser.expression.JdbcParameter;
1415
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
@@ -1240,6 +1241,17 @@ private List<QueryColumn> resolveColumns(PlainSelect plainSelect, List<Source> s
12401241
continue;
12411242
}
12421243

1244+
if (expression instanceof Function function && isScalarCount(function)) {
1245+
columns.add(
1246+
scalarCountColumn(
1247+
selectItem.getAlias(),
1248+
plainSelect.getSelectItems().size()
1249+
)
1250+
);
1251+
1252+
continue;
1253+
}
1254+
12431255
throw new UnsupportedOperationException(
12441256
"Unsupported SELECT expression: "
12451257
+ expression.getClass().getSimpleName()
@@ -1249,6 +1261,53 @@ private List<QueryColumn> resolveColumns(PlainSelect plainSelect, List<Source> s
12491261
return columns;
12501262
}
12511263

1264+
/**
1265+
* Reports whether a projected function is the supported scalar count, which
1266+
* is an unqualified case-insensitive {@code COUNT} whose single argument is
1267+
* exactly {@code *}. A qualified name such as {@code pg_catalog.count(*)},
1268+
* a {@code DISTINCT} count, and {@code COUNT(t.*)}, whose argument is a
1269+
* subtype of the {@code COUNT(*)} argument, are other functions.
1270+
*/
1271+
private boolean isScalarCount(Function function) {
1272+
List<String> name = function.getMultipartName();
1273+
1274+
if (name == null || name.size() != 1 || !name.getFirst().equalsIgnoreCase("count")) {
1275+
return false;
1276+
}
1277+
1278+
if (function.isDistinct()) {
1279+
return false;
1280+
}
1281+
1282+
ExpressionList<?> arguments = function.getParameters();
1283+
1284+
return arguments != null
1285+
&& arguments.size() == 1
1286+
&& arguments.getFirst().getClass() == AllColumns.class;
1287+
}
1288+
1289+
/**
1290+
* Resolves the scalar count projection into its single result column, which
1291+
* is named after the required alias. The column is {@code BIGINT} because
1292+
* {@code count(*)} returns {@code bigint}, and it is non-null because a
1293+
* count is always a number, {@code 0} when no row matches.
1294+
*/
1295+
private QueryColumn scalarCountColumn(Alias alias, int selectItemCount) {
1296+
if (selectItemCount > 1) {
1297+
throw new UnsupportedOperationException(
1298+
"COUNT(*) must be the only SELECT item."
1299+
);
1300+
}
1301+
1302+
if (alias == null) {
1303+
throw new UnsupportedOperationException(
1304+
"COUNT(*) requires a result alias, such as COUNT(*) AS total."
1305+
);
1306+
}
1307+
1308+
return new QueryColumn(alias.getUnquotedName(), ColumnType.BIGINT, false);
1309+
}
1310+
12521311
/**
12531312
* Reports the table whose complete row a read returns, which is the single
12541313
* query source when the projection is exactly {@code *} or

‎sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,6 +1232,85 @@ void shouldRejectPlaceholderInUnsupportedPaginationValue(String sql) {
12321232
);
12331233
}
12341234

1235+
@Test
1236+
void shouldResolveScalarCountColumnFromItsAlias() {
1237+
String sql = "SELECT COUNT(*) AS total FROM users";
1238+
1239+
QueryModel model = analyzer.analyze(
1240+
new Query("CountUsers", QueryType.ONE, sql),
1241+
parser.parse(sql),
1242+
schema
1243+
);
1244+
1245+
assertEquals(
1246+
List.of(new QueryColumn("total", ColumnType.BIGINT, false)),
1247+
model.columns()
1248+
);
1249+
1250+
assertTrue(model.parameters().isEmpty());
1251+
assertNull(model.rowTable());
1252+
}
1253+
1254+
@Test
1255+
void shouldResolveScalarCountBesidePredicateParameter() {
1256+
String sql = "SELECT count(*) user_count FROM users WHERE name LIKE $1";
1257+
1258+
QueryModel model = analyzer.analyze(
1259+
new Query("CountMatchingUsers", QueryType.ONE, sql),
1260+
parser.parse(sql),
1261+
schema
1262+
);
1263+
1264+
assertEquals(
1265+
List.of(new QueryColumn("user_count", ColumnType.BIGINT, false)),
1266+
model.columns()
1267+
);
1268+
1269+
assertEquals(
1270+
List.of(new QueryParameter(1, "name", ColumnType.VARCHAR)),
1271+
model.parameters()
1272+
);
1273+
}
1274+
1275+
@ParameterizedTest
1276+
@CsvSource(
1277+
delimiter = '|',
1278+
quoteCharacter = '"',
1279+
value = {
1280+
"SELECT COUNT(*) FROM users"
1281+
+ "|COUNT(*) requires a result alias, such as COUNT(*) AS total.",
1282+
"SELECT COUNT(*) AS n, id FROM users"
1283+
+ "|COUNT(*) must be the only SELECT item.",
1284+
"SELECT id, COUNT(*) AS n FROM users"
1285+
+ "|COUNT(*) must be the only SELECT item.",
1286+
"SELECT COUNT(id) AS n FROM users"
1287+
+ "|Unsupported SELECT expression: Function",
1288+
"SELECT COUNT(DISTINCT id) AS n FROM users"
1289+
+ "|Unsupported SELECT expression: Function",
1290+
"SELECT COUNT(u.*) AS n FROM users u"
1291+
+ "|Unsupported SELECT expression: Function",
1292+
"SELECT pg_catalog.count(*) AS n FROM users"
1293+
+ "|Unsupported SELECT expression: Function",
1294+
"SELECT lower(name) AS n FROM users"
1295+
+ "|Unsupported SELECT expression: Function",
1296+
"SELECT COUNT(*) FILTER (WHERE id > 1) AS n FROM users"
1297+
+ "|Unsupported SELECT expression: AnalyticExpression",
1298+
"SELECT COUNT(*) OVER () AS n FROM users"
1299+
+ "|Unsupported SELECT expression: AnalyticExpression"
1300+
}
1301+
)
1302+
void shouldRejectUnsupportedCountProjectionForm(String sql, String message) {
1303+
Query query = new Query("CountUsers", QueryType.ONE, sql);
1304+
ParsedSql parsedSql = parser.parse(sql);
1305+
1306+
UnsupportedOperationException exception = assertThrows(
1307+
UnsupportedOperationException.class,
1308+
() -> analyzer.analyze(query, parsedSql, schema)
1309+
);
1310+
1311+
assertEquals(message, exception.getMessage());
1312+
}
1313+
12351314
@Test
12361315
void shouldResolveQueryParametersInsideInExpression() {
12371316
Query query = new Query(

‎sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,52 @@ INSERT INTO users (id, code, name)
561561
}
562562
}
563563

564+
/**
565+
* Covers the scalar count end to end: a {@code :one} count read is
566+
* generated, compiled, and executed against PostgreSQL, so its result
567+
* record exposes one {@code Long} component that counts the matching rows
568+
* and is {@code 0} when none match.
569+
*/
570+
@Test
571+
void shouldExecuteGeneratedScalarCountAgainstPostgres() throws Exception {
572+
Path classesDirectory = generateAndCompile("""
573+
-- name: CountUsersByName :one
574+
SELECT COUNT(*) AS total
575+
FROM users
576+
WHERE name LIKE $1;
577+
""");
578+
579+
execute("""
580+
INSERT INTO users (id, code, name)
581+
VALUES
582+
(1, 1, 'Alice'),
583+
(2, 2, 'Amy'),
584+
(3, 3, 'Bob')
585+
""");
586+
587+
try (URLClassLoader classLoader = classLoader(classesDirectory)) {
588+
Object repository = newRepository(classLoader);
589+
590+
Method count = repository.getClass().getMethod(
591+
"countUsersByName",
592+
String.class
593+
);
594+
595+
Object matching = count.invoke(repository, "A%");
596+
597+
assertEquals(List.of("total"), recordComponentNames(matching));
598+
599+
assertEquals(
600+
Long.class,
601+
matching.getClass().getRecordComponents()[0].getType()
602+
);
603+
604+
assertEquals(2L, component(matching, "total"));
605+
606+
assertEquals(0L, component(count.invoke(repository, "Z%"), "total"));
607+
}
608+
}
609+
564610
@Test
565611
void shouldExecuteGeneratedWriteAgainstPostgres() throws Exception {
566612
Path classesDirectory = generateAndCompile("""

0 commit comments

Comments
 (0)