From e6fdecd9384825376f3fb69f407470aa59a637a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Fri, 25 Sep 2026 01:38:32 +0300 Subject: [PATCH 1/2] feat(analysis): type LIKE and ILIKE patterns and resolve null tests --- .../dev/sqlcj/analysis/QueryAnalyzer.java | 112 +++++++++++ .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 174 ++++++++++++++++++ .../compiler/PostgresIntegrationTest.java | 114 ++++++++++++ 3 files changed, 400 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 6afeadd..d2573c6 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -2,6 +2,7 @@ import dev.sqlcj.parser.Query; import dev.sqlcj.parser.QueryType; +import dev.sqlcj.schema.ColumnType; import dev.sqlcj.schema.Schema; import dev.sqlcj.sql.ParsedSql; import dev.sqlcj.type.DefaultTypeResolver; @@ -16,6 +17,8 @@ import net.sf.jsqlparser.expression.operators.relational.EqualsTo; import net.sf.jsqlparser.expression.operators.relational.ExpressionList; import net.sf.jsqlparser.expression.operators.relational.InExpression; +import net.sf.jsqlparser.expression.operators.relational.IsNullExpression; +import net.sf.jsqlparser.expression.operators.relational.LikeExpression; import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.ReturningClause; @@ -758,6 +761,16 @@ private void resolveParameters( return; } + if (expression instanceof LikeExpression like) { + resolveLikeExpression(like, sources, parameters); + return; + } + + if (expression instanceof IsNullExpression isNull) { + resolveIsNullExpression(isNull, sources); + return; + } + if (expression instanceof ComparisonOperator comparison) { resolveParameterComparison( comparison.getLeftExpression(), @@ -863,6 +876,105 @@ private void resolveInExpression( } } + /** + * Resolves the pattern parameter of {@code LIKE $N} or + * {@code ILIKE $N}, typed from the tested text column. A pattern + * that binds no placeholder reaches the database as written, so a literal + * pattern stays unanalyzed. + */ + private void resolveLikeExpression( + LikeExpression like, + List sources, + List parameters + ) { + Expression left = like.getLeftExpression(); + Expression right = like.getRightExpression(); + + requireIndexedParameter(left); + requireIndexedParameter(right); + + if (left instanceof JdbcParameter) { + throw new UnsupportedOperationException( + "A LIKE placeholder must be the pattern, not the tested value." + ); + } + + if (!(right instanceof JdbcParameter parameter)) { + return; + } + + requireSupportedLikePattern(like); + + if (!(left instanceof net.sf.jsqlparser.schema.Column column)) { + return; + } + + addParameter( + parameter, + requireTextColumn(resolveColumn(column, sources).column()), + parameters + ); + } + + /** + * Requires the exact {@code LIKE}/{@code ILIKE} pattern shape this subset + * types, so a related keyword or modifier is never analyzed as a plain + * pattern match. + */ + private void requireSupportedLikePattern(LikeExpression like) { + if (like.isNot()) { + throw new UnsupportedOperationException( + "A negated LIKE pattern placeholder is not supported." + ); + } + + LikeExpression.KeyWord keyword = like.getLikeKeyWord(); + + if (keyword != LikeExpression.KeyWord.LIKE && keyword != LikeExpression.KeyWord.ILIKE) { + throw new UnsupportedOperationException( + "Only LIKE and ILIKE pattern placeholders are supported, but was: " + keyword + ); + } + + if (like.getEscape() != null) { + throw new UnsupportedOperationException( + "A LIKE pattern placeholder must not have an ESCAPE clause." + ); + } + + if (like.isUseBinary()) { + throw new UnsupportedOperationException( + "A binary LIKE pattern placeholder is not supported." + ); + } + } + + /** + * Requires a text column, because the pattern parameter takes the tested + * column's type and only a character type makes that type a pattern. + */ + private dev.sqlcj.schema.Column requireTextColumn(dev.sqlcj.schema.Column column) { + if (column.type() != ColumnType.VARCHAR && column.type() != ColumnType.TEXT) { + throw new UnsupportedOperationException( + "A LIKE pattern placeholder requires a VARCHAR or TEXT column, but %s is %s." + .formatted(column.name(), column.type()) + ); + } + + return column; + } + + /** + * Resolves the tested column of {@code IS NULL} and {@code IS NOT NULL} + * against the query sources. The predicate binds no placeholder, so it + * contributes no parameter and reaches the database as written. + */ + private void resolveIsNullExpression(IsNullExpression isNull, List sources) { + if (isNull.getLeftExpression() instanceof net.sf.jsqlparser.schema.Column column) { + resolveColumn(column, sources); + } + } + 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 a5d826f..38c85a7 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -36,6 +36,21 @@ class QueryAnalyzerTest { ) ); + /** A schema with both text column types, for the text predicate forms. */ + private static final Schema predicateSchema = new Schema( + List.of( + new Table( + "users", + List.of( + new Column("id", ColumnType.BIGINT, false), + new Column("name", ColumnType.VARCHAR, true), + new Column("bio", ColumnType.TEXT, true) + ), + List.of() + ) + ) + ); + private static final Schema joinSchema = new Schema( List.of( new Table( @@ -766,6 +781,165 @@ void shouldResolveQueryParameterWhenParameterIsOnLeftSide() { assertEquals(ColumnType.BIGINT, parameter.type()); } + @Test + void shouldResolveLikePatternParameterInTextualBindingOrder() { + String sql = "SELECT id FROM users WHERE name ILIKE $2 AND id > $1"; + + QueryModel model = analyzer.analyze( + new Query("SearchUsers", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR) + ), + model.parameters() + ); + + assertEquals(List.of(2, 1), model.bindingParameterIndexes()); + + assertEquals( + "SELECT id FROM users WHERE name ILIKE ? AND id > ?", + model.executableSql() + ); + } + + @Test + void shouldResolveLikePatternParameterFromTextColumn() { + String sql = "SELECT id FROM users WHERE bio LIKE $1"; + + QueryModel model = analyzer.analyze( + new Query("SearchUsers", QueryType.MANY, sql), + parser.parse(sql), + predicateSchema + ); + + assertEquals( + List.of(new QueryParameter(1, "bio", ColumnType.TEXT)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + } + + @Test + void shouldNotCreateParameterForLiteralLikePattern() { + String sql = "SELECT id FROM users WHERE name LIKE 'A%' AND id = $1"; + + QueryModel model = analyzer.analyze( + new Query("SearchUsers", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)), + model.parameters() + ); + + assertEquals( + "SELECT id FROM users WHERE name LIKE 'A%' AND id = ?", + model.executableSql() + ); + } + + @Test + void shouldResolveNullPredicateColumnsWithoutParameters() { + String sql = """ + SELECT u.id + FROM users u + WHERE u.bio IS NOT NULL + AND bio IS NULL + AND u.id = $1 + """; + + QueryModel model = analyzer.analyze( + new Query("FindUser", QueryType.OPTIONAL, sql), + parser.parse(sql), + predicateSchema + ); + + assertEquals( + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + } + + @Test + void shouldRejectUnknownColumnInNullPredicate() { + String sql = "SELECT id FROM users WHERE missing IS NULL"; + + 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() + ); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "SELECT id FROM users WHERE name NOT LIKE $1" + + "|A negated LIKE pattern placeholder is not supported.", + "SELECT id FROM users WHERE name NOT ILIKE $1" + + "|A negated LIKE pattern placeholder is not supported.", + "SELECT id FROM users WHERE name SIMILAR TO $1" + + "|Only LIKE and ILIKE pattern placeholders are supported, but was: SIMILAR_TO", + "SELECT id FROM users WHERE name LIKE $1 ESCAPE '!'" + + "|A LIKE pattern placeholder must not have an ESCAPE clause.", + "SELECT id FROM users WHERE name LIKE BINARY $1" + + "|A binary LIKE pattern placeholder is not supported.", + "SELECT id FROM users WHERE id LIKE $1" + + "|A LIKE pattern placeholder requires a VARCHAR or TEXT column, but id is BIGINT.", + "SELECT id FROM users WHERE $1 LIKE name" + + "|A LIKE placeholder must be the pattern, not the tested value.", + "SELECT id FROM users WHERE name LIKE :pattern" + + "|Named parameter ':pattern' is not supported; use an indexed placeholder such as $1" + } + ) + void shouldRejectUnsupportedLikePlaceholderForm(String sql, String message) { + Query query = new Query("SearchUsers", QueryType.MANY, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, schema) + ); + + assertEquals(message, exception.getMessage()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "SELECT id FROM users WHERE name LIKE '%' || $1 || '%'", + "SELECT id FROM users WHERE $1 IS NULL" + } + ) + void shouldRejectPlaceholderThatIsNotAnAnalyzedPredicateOperand(String sql) { + Query query = new Query("SearchUsers", QueryType.MANY, sql); + ParsedSql parsedSql = parser.parse(sql); + + assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, schema) + ); + } + @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 62e4073..d90bf4e 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -343,6 +343,109 @@ INSERT INTO users (id, code, name, active) } } + /** + * Covers the text and null predicates end to end: a {@code LIKE} pattern on + * a {@code VARCHAR} column, a case-insensitive {@code ILIKE} pattern on a + * {@code TEXT} column, and both null tests are generated, compiled, and + * executed against PostgreSQL. + */ + @Test + void shouldExecuteGeneratedTextAndNullPredicatesAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: SearchUsersByName :many + SELECT id, name + FROM users + WHERE name LIKE $1 + ORDER BY id; + + -- name: SearchUsersByBio :many + SELECT id, name + FROM users + WHERE bio LIKE $1 + ORDER BY id; + + -- name: SearchUsersByBioIgnoringCase :many + SELECT u.id, u.name + FROM users u + WHERE u.bio ILIKE $1 + ORDER BY u.id; + + -- name: ListUsersWithoutBio :many + SELECT id, name + FROM users + WHERE bio IS NULL + ORDER BY id; + + -- name: ListUsersWithBio :many + SELECT id, name + FROM users u + WHERE u.bio IS NOT NULL + ORDER BY id; + """); + + execute(""" + INSERT INTO users (id, code, name, bio) + VALUES + (1, 1, 'Alice', 'Writes POETRY'), + (2, 2, 'Albert', NULL), + (3, 3, 'Bob', 'writes poetry too') + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Method searchByName = repository.getClass().getMethod( + "searchUsersByName", + String.class + ); + + Method searchByBio = repository.getClass().getMethod( + "searchUsersByBio", + String.class + ); + + Method searchByBioIgnoringCase = repository.getClass().getMethod( + "searchUsersByBioIgnoringCase", + String.class + ); + + assertEquals( + List.of("Alice", "Albert"), + names(searchByName.invoke(repository, "Al%")) + ); + + assertEquals( + List.of("Alice"), + names(searchByBio.invoke(repository, "%POETRY%")) + ); + + assertEquals( + List.of("Alice", "Bob"), + names(searchByBioIgnoringCase.invoke(repository, "%POETRY%")) + ); + + assertEquals( + List.of("Albert"), + names( + repository + .getClass() + .getMethod("listUsersWithoutBio") + .invoke(repository) + ) + ); + + assertEquals( + List.of("Alice", "Bob"), + names( + repository + .getClass() + .getMethod("listUsersWithBio") + .invoke(repository) + ) + ); + } + } + @Test void shouldExecuteGeneratedWriteAgainstPostgres() throws Exception { Path classesDirectory = generateAndCompile(""" @@ -1104,6 +1207,17 @@ private List listUserNames( return names; } + /** Reads the {@code name} component of every row of a generated list result. */ + private List names(Object rows) throws Exception { + List names = new ArrayList<>(); + + for (Object row : (List) rows) { + names.add(component(row, "name")); + } + + return names; + } + private Path generateAndCompile(String queries) throws Exception { return generateAndCompile(SCHEMA, queries); } From ff7125ec3ad6cecd077e18c718ec6f7511ef7224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Fri, 25 Sep 2026 01:38:41 +0300 Subject: [PATCH 2/2] docs: document LIKE, ILIKE, and null-test predicates --- docs/queries.md | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/queries.md b/docs/queries.md index a9409cc..3bcdd28 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -113,7 +113,23 @@ A `WHERE` clause may combine: - the comparisons `=`, `<>`, `>`, `>=`, `<`, `<=` between a direct column and a `$N` placeholder, in either order, - `AND`, `OR`, and parentheses, -- `IN` with a fixed list of `$N` placeholders, such as `id IN ($1, $2)`. +- `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`. + +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 +the pattern through unchanged, so the caller supplies the `%` and `_` wildcards +in the argument, as in `"Al%"`. A negated `NOT LIKE` or `NOT ILIKE`, another +keyword such as `SIMILAR TO`, an `ESCAPE` clause, a `BINARY` modifier, a +non-text tested column, and a placeholder as the tested value are rejected. A +placeholder inside a computed pattern, such as `'%' || $1 || '%'`, is rejected +as an unanalyzed placeholder location. + +`IS NULL` and `IS NOT NULL` bind no placeholder, but their column is resolved +against the query sources, so an unknown, ambiguous, or badly qualified column +is rejected. A comparison that binds no placeholder, such as `active = TRUE`, contributes no generated parameter and reaches the database as written. @@ -384,9 +400,13 @@ 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 `LIKE $1`, - `BETWEEN $1 AND $2`, and `LIMIT $1`, so parameterized pagination and dynamic - `IN` expansion are unavailable. +- 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 `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. - 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, @@ -405,8 +425,9 @@ the generated Java describes only the part sqlcj did analyze. Do not rely on them: - `DISTINCT`, `GROUP BY`, and `HAVING`, -- predicate forms other than the listed comparisons, `AND`/`OR`, and fixed `IN` - lists, such as `IS NULL`, `LIKE` with a literal, or `IN` with a subquery, +- 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, - 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 @@ -484,6 +505,17 @@ Reads: - `PostgresIntegrationTest.shouldExecuteGeneratedOneQueryAgainstPostgres` and `PostgresIntegrationTest.shouldExecuteGeneratedManyQueryAgainstPostgres` execute an ordered list read against PostgreSQL 16. +- `QueryAnalyzerTest.shouldResolveLikePatternParameterInTextualBindingOrder`, + `QueryAnalyzerTest.shouldResolveLikePatternParameterFromTextColumn`, + `QueryAnalyzerTest.shouldNotCreateParameterForLiteralLikePattern`, + `QueryAnalyzerTest.shouldResolveNullPredicateColumnsWithoutParameters`, + `QueryAnalyzerTest.shouldRejectUnknownColumnInNullPredicate`, + `QueryAnalyzerTest.shouldRejectUnsupportedLikePlaceholderForm`, and + `QueryAnalyzerTest.shouldRejectPlaceholderThatIsNotAnAnalyzedPredicateOperand` + cover the pattern and null predicates, and + `PostgresIntegrationTest.shouldExecuteGeneratedTextAndNullPredicatesAgainstPostgres` + executes `LIKE`, a case-insensitive `ILIKE`, `IS NULL`, and `IS NOT NULL` + reads against PostgreSQL 16. - `QueryAnalyzerTest.shouldResolveRowTableForFullRowSelect`, `QueryAnalyzerTest.shouldNotResolveRowTableForQuerySpecificResult`, `QueryAnalyzerTest.shouldNotResolveRowTableForJoinedWildcard`, and