Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1240,6 +1241,17 @@ private List<QueryColumn> resolveColumns(PlainSelect plainSelect, List<Source> 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()
Expand All @@ -1249,6 +1261,53 @@ private List<QueryColumn> resolveColumns(PlainSelect plainSelect, List<Source> 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<String> 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
Expand Down
79 changes: 79 additions & 0 deletions sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand Down
Loading