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
42 changes: 35 additions & 7 deletions docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ A `WHERE` clause may combine:
- `IN` with a fixed list of `$N` placeholders, such as `id IN ($1, $2)`,
- `LIKE` and `ILIKE` between a direct column and a `$N` pattern placeholder,
such as `name LIKE $1`,
- `IS NULL` and `IS NOT NULL` on a direct column, such as `bio IS NULL`.
- `IS NULL` and `IS NOT NULL` on a direct column, such as `bio IS NULL`,
- `BETWEEN` and `NOT BETWEEN` on a direct column, such as
`code BETWEEN $1 AND $2`.

A `LIKE` or `ILIKE` pattern placeholder requires a `VARCHAR` or `TEXT` column
and takes that column's type, so it is a `String` method parameter. sqlcj passes
Expand All @@ -131,6 +133,17 @@ as an unanalyzed placeholder location.
against the query sources, so an unknown, ambiguous, or badly qualified column
is rejected.

A `BETWEEN` or `NOT BETWEEN` bound that is a `$N` placeholder takes the tested
column's type, so `code BETWEEN $1 AND $2` binds two `Integer` parameters named
after `code` and disambiguated as `code1` and `code2`. The bounds are bound in
textual order, the start bound before the end bound, whatever their placeholder
indexes are. A placeholder bound beside a literal bound, as in
`code BETWEEN $1 AND 10`, is typed the same way and binds one parameter. A named
bound such as `code BETWEEN :lo AND :hi` is rejected like any other named
placeholder, while a placeholder as the tested value, as in
`$1 BETWEEN code AND code`, and a placeholder inside a computed bound, as in
`code BETWEEN $1 + 1 AND $2`, are rejected as unanalyzed placeholder locations.

A comparison that binds no placeholder, such as `active = TRUE`, contributes no
generated parameter and reaches the database as written.

Expand Down Expand Up @@ -400,13 +413,16 @@ name, and its header line.
- 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`.
- A placeholder in a location sqlcj does not analyze, including
`BETWEEN $1 AND $2`, `LIMIT $1`, and a computed `LIKE` pattern such as
`'%' || $1 || '%'`, so parameterized pagination and dynamic `IN` expansion are
unavailable.
- A placeholder in a location sqlcj does not analyze, including `LIMIT $1`, a
computed `LIKE` pattern such as `'%' || $1 || '%'`, a placeholder as the
tested value of a range such as `$1 BETWEEN id AND id`, and a computed range
bound such as `id BETWEEN $1 + 1 AND $2`, so parameterized pagination and
dynamic `IN` expansion are unavailable.
- 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.
- A named range bound such as `id BETWEEN :lo AND :hi`, which fails with the
named-placeholder diagnostic.
- Anonymous `?` and named `:name` placeholders, and non-contiguous or
non-positive placeholder indexes.
- An `INSERT` without an explicit column list, with more than one `VALUES` row,
Expand All @@ -426,8 +442,9 @@ them:

- `DISTINCT`, `GROUP BY`, and `HAVING`,
- predicate forms other than the listed comparisons, `AND`/`OR`, fixed `IN`
lists, pattern placeholders, and null tests, such as `LIKE` with a literal
pattern or `IN` with a subquery,
lists, pattern placeholders, null tests, and ranges, such as `LIKE` with a
literal pattern, a range whose bounds are both literal such as
`id BETWEEN 1 AND 10`, or `IN` with a subquery,
- common table expressions, and subqueries outside the `FROM` item,
- `LIMIT` and `OFFSET` with literal values,
- `ON CONFLICT`, `UPDATE ... FROM`, and `DELETE ... USING` on a non-returning
Expand Down Expand Up @@ -516,6 +533,17 @@ Reads:
`PostgresIntegrationTest.shouldExecuteGeneratedTextAndNullPredicatesAgainstPostgres`
executes `LIKE`, a case-insensitive `ILIKE`, `IS NULL`, and `IS NOT NULL`
reads against PostgreSQL 16.
- `QueryAnalyzerTest.shouldResolveRangeBoundParametersFromTestedColumn`,
`QueryAnalyzerTest.shouldResolveNegatedRangeBoundParameters`,
`QueryAnalyzerTest.shouldResolveRangeBoundParametersInTextualBindingOrder`,
`QueryAnalyzerTest.shouldResolveRangeBoundParameterBesideLiteralBound`,
`QueryAnalyzerTest.shouldNotCreateParameterForLiteralRange`,
`QueryAnalyzerTest.shouldRejectUnknownColumnInRangePredicate`, and
`QueryAnalyzerTest.shouldRejectNamedRangeBound` cover the range predicates,
and
`PostgresIntegrationTest.shouldExecuteGeneratedRangePredicatesAgainstPostgres`
executes a `BETWEEN` read and a `NOT BETWEEN` read whose bounds use
out-of-order placeholder indexes against PostgreSQL 16.
- `QueryAnalyzerTest.shouldResolveRowTableForFullRowSelect`,
`QueryAnalyzerTest.shouldNotResolveRowTableForQuerySpecificResult`,
`QueryAnalyzerTest.shouldNotResolveRowTableForJoinedWildcard`, and
Expand Down
45 changes: 45 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 @@ -13,6 +13,7 @@
import net.sf.jsqlparser.expression.JdbcParameter;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
import net.sf.jsqlparser.expression.operators.relational.Between;
import net.sf.jsqlparser.expression.operators.relational.ComparisonOperator;
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
Expand Down Expand Up @@ -771,6 +772,11 @@ private void resolveParameters(
return;
}

if (expression instanceof Between between) {
resolveBetweenExpression(between, sources, parameters);
return;
}

if (expression instanceof ComparisonOperator comparison) {
resolveParameterComparison(
comparison.getLeftExpression(),
Expand Down Expand Up @@ -975,6 +981,45 @@ private void resolveIsNullExpression(IsNullExpression isNull, List<Source> sourc
}
}

/**
* Resolves the bounds of {@code <column> BETWEEN $a AND $b} and its
* negation, typing each placeholder bound from the tested column and adding
* the start bound before the end bound. A bound that binds no placeholder
* reaches the database as written, so a range of literal bounds stays
* unanalyzed.
*/
private void resolveBetweenExpression(
Between between,
List<Source> sources,
List<QueryParameter> parameters
) {
Expression left = between.getLeftExpression();
Expression start = between.getBetweenExpressionStart();
Expression end = between.getBetweenExpressionEnd();

requireIndexedParameter(left);
requireIndexedParameter(start);
requireIndexedParameter(end);

if (!(start instanceof JdbcParameter) && !(end instanceof JdbcParameter)) {
return;
}

if (!(left instanceof net.sf.jsqlparser.schema.Column column)) {
return;
}

dev.sqlcj.schema.Column schemaColumn = resolveColumn(column, sources).column();

if (start instanceof JdbcParameter startParameter) {
addParameter(startParameter, schemaColumn, parameters);
}

if (end instanceof JdbcParameter endParameter) {
addParameter(endParameter, schemaColumn, parameters);
}
}

private void resolveParameterComparison(
Expression left,
Expression right,
Expand Down
158 changes: 157 additions & 1 deletion sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -923,11 +923,167 @@ void shouldRejectUnsupportedLikePlaceholderForm(String sql, String message) {
assertEquals(message, exception.getMessage());
}

@Test
void shouldResolveRangeBoundParametersFromTestedColumn() {
String sql = "SELECT id FROM users WHERE id BETWEEN $1 AND $2";

QueryModel model = analyzer.analyze(
new Query("ListUsers", QueryType.MANY, sql),
parser.parse(sql),
schema
);

assertEquals(
List.of(
new QueryParameter(1, "id", ColumnType.BIGINT),
new QueryParameter(2, "id", ColumnType.BIGINT)
),
model.parameters()
);

assertEquals(List.of(1, 2), model.bindingParameterIndexes());

assertEquals(
"SELECT id FROM users WHERE id BETWEEN ? AND ?",
model.executableSql()
);
}

@Test
void shouldResolveNegatedRangeBoundParameters() {
String sql = "SELECT id FROM users WHERE id NOT BETWEEN $1 AND $2";

QueryModel model = analyzer.analyze(
new Query("ListUsers", QueryType.MANY, sql),
parser.parse(sql),
schema
);

assertEquals(
List.of(
new QueryParameter(1, "id", ColumnType.BIGINT),
new QueryParameter(2, "id", ColumnType.BIGINT)
),
model.parameters()
);

assertEquals(List.of(1, 2), model.bindingParameterIndexes());

assertEquals(
"SELECT id FROM users WHERE id NOT BETWEEN ? AND ?",
model.executableSql()
);
}

@Test
void shouldResolveRangeBoundParametersInTextualBindingOrder() {
String sql = "SELECT id FROM users WHERE name = $1 AND id NOT BETWEEN $3 AND $2";

QueryModel model = analyzer.analyze(
new Query("ListUsers", QueryType.MANY, sql),
parser.parse(sql),
schema
);

assertEquals(
List.of(
new QueryParameter(1, "name", ColumnType.VARCHAR),
new QueryParameter(2, "id", ColumnType.BIGINT),
new QueryParameter(3, "id", ColumnType.BIGINT)
),
model.parameters()
);

assertEquals(List.of(1, 3, 2), model.bindingParameterIndexes());
}

@Test
void shouldResolveRangeBoundParameterBesideLiteralBound() {
String sql = "SELECT id FROM users WHERE id BETWEEN $1 AND 10";

QueryModel model = analyzer.analyze(
new Query("ListUsers", QueryType.MANY, sql),
parser.parse(sql),
schema
);

assertEquals(
List.of(new QueryParameter(1, "id", ColumnType.BIGINT)),
model.parameters()
);

assertEquals(List.of(1), model.bindingParameterIndexes());

assertEquals(
"SELECT id FROM users WHERE id BETWEEN ? AND 10",
model.executableSql()
);
}

@Test
void shouldNotCreateParameterForLiteralRange() {
String sql = "SELECT id FROM users WHERE id BETWEEN 1 AND 10 AND name = $1";

QueryModel model = analyzer.analyze(
new Query("ListUsers", QueryType.MANY, sql),
parser.parse(sql),
schema
);

assertEquals(
List.of(new QueryParameter(1, "name", ColumnType.VARCHAR)),
model.parameters()
);

assertEquals(
"SELECT id FROM users WHERE id BETWEEN 1 AND 10 AND name = ?",
model.executableSql()
);
}

@Test
void shouldRejectUnknownColumnInRangePredicate() {
String sql = "SELECT id FROM users WHERE missing BETWEEN $1 AND $2";

Query query = new Query("ListUsers", QueryType.MANY, sql);
ParsedSql parsedSql = parser.parse(sql);

IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> analyzer.analyze(query, parsedSql, schema)
);

assertEquals(
"Column not found in sources users: missing",
exception.getMessage()
);
}

@Test
void shouldRejectNamedRangeBound() {
String sql = "SELECT id FROM users WHERE id BETWEEN :lo AND :hi";

Query query = new Query("ListUsers", QueryType.MANY, sql);
ParsedSql parsedSql = parser.parse(sql);

UnsupportedOperationException exception = assertThrows(
UnsupportedOperationException.class,
() -> analyzer.analyze(query, parsedSql, schema)
);

assertEquals(
"Named parameter ':lo' is not supported; use an indexed placeholder such as $1",
exception.getMessage()
);
}

@ParameterizedTest
@ValueSource(
strings = {
"SELECT id FROM users WHERE name LIKE '%' || $1 || '%'",
"SELECT id FROM users WHERE $1 IS NULL"
"SELECT id FROM users WHERE $1 IS NULL",
"SELECT id FROM users WHERE $1 BETWEEN id AND id",
"SELECT id FROM users WHERE id BETWEEN $1 + 1 AND $2"
}
)
void shouldRejectPlaceholderThatIsNotAnAnalyzedPredicateOperand(String sql) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,63 @@ INSERT INTO users (id, code, name, bio)
}
}

/**
* Covers the range predicates end to end: a {@code BETWEEN} range and a
* {@code NOT BETWEEN} range whose bounds use out-of-order placeholder
* indexes are generated, compiled, and executed against PostgreSQL, so a
* swapped binding order would change the returned names.
*/
@Test
void shouldExecuteGeneratedRangePredicatesAgainstPostgres() throws Exception {
Path classesDirectory = generateAndCompile("""
-- name: ListUsersInCodeRange :many
SELECT id, name
FROM users
WHERE code BETWEEN $1 AND $2
ORDER BY id;

-- name: ListUsersOutsideCodeRange :many
SELECT id, name
FROM users
WHERE code NOT BETWEEN $2 AND $1
ORDER BY id;
""");

execute("""
INSERT INTO users (id, code, name)
VALUES
(1, 5, 'Alice'),
(2, 20, 'Bob'),
(3, 40, 'Cara')
""");

try (URLClassLoader classLoader = classLoader(classesDirectory)) {
Object repository = newRepository(classLoader);

Method inRange = repository.getClass().getMethod(
"listUsersInCodeRange",
Integer.class,
Integer.class
);

Method outsideRange = repository.getClass().getMethod(
"listUsersOutsideCodeRange",
Integer.class,
Integer.class
);

assertEquals(
List.of("Bob"),
names(inRange.invoke(repository, 10, 30))
);

assertEquals(
List.of("Alice", "Cara"),
names(outsideRange.invoke(repository, 30, 10))
);
}
}

@Test
void shouldExecuteGeneratedWriteAgainstPostgres() throws Exception {
Path classesDirectory = generateAndCompile("""
Expand Down
Loading