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
44 changes: 38 additions & 6 deletions docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
112 changes: 112 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 @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -863,6 +876,105 @@ private void resolveInExpression(
}
}

/**
* Resolves the pattern parameter of {@code <column> LIKE $N} or
* {@code <column> 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<Source> sources,
List<QueryParameter> 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<Source> sources) {
if (isNull.getLeftExpression() instanceof net.sf.jsqlparser.schema.Column column) {
resolveColumn(column, sources);
}
}

private void resolveParameterComparison(
Expression left,
Expression right,
Expand Down
174 changes: 174 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 @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading