From 941ec3ee2a56f3b48ab7f6bdd617db0df453a708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Fri, 25 Sep 2026 01:58:59 +0300 Subject: [PATCH 1/2] feat(analysis): type BETWEEN and NOT BETWEEN range bound placeholders --- .../dev/sqlcj/analysis/QueryAnalyzer.java | 45 +++++ .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 158 +++++++++++++++++- .../compiler/PostgresIntegrationTest.java | 57 +++++++ 3 files changed, 259 insertions(+), 1 deletion(-) 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 d2573c6..dab22e6 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -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; @@ -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(), @@ -975,6 +981,45 @@ private void resolveIsNullExpression(IsNullExpression isNull, List sourc } } + /** + * Resolves the bounds of {@code 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 sources, + List 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, 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 38c85a7..1143eac 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -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) { 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 d90bf4e..77565f3 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -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(""" From 7cc277541d1a7a148bfc0f990b99d30fb5ed8bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Fri, 25 Sep 2026 01:59:09 +0300 Subject: [PATCH 2/2] docs: document BETWEEN and NOT BETWEEN range predicates --- docs/queries.md | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/queries.md b/docs/queries.md index 3bcdd28..cf0b858 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -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 @@ -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. @@ -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, @@ -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 @@ -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