From 06797df22d1f55b36c7763e1f0a3e87f77b9f26d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sun, 4 Oct 2026 23:06:52 +0300 Subject: [PATCH 1/2] feat(analysis): bind column = ANY placeholders as typed lists --- .../dev/sqlcj/analysis/QueryAnalyzer.java | 121 +++++++- .../schema/parser/ColumnTypeMapping.java | 5 +- .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 292 ++++++++++++++++++ .../compiler/PostgresIntegrationTest.java | 104 +++++++ .../SqlcjCompilerIntegrationTest.java | 46 +++ 5 files changed, 565 insertions(+), 3 deletions(-) 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 1805f11..7eac070 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -1551,8 +1551,9 @@ private void resolveBetweenExpression( * Resolves the operands of one comparison, which names a parameter after * the column it compares. A placeholder compared with a column takes that * column's type, and a cast placeholder states its own type and keeps the - * column's name. Every other operand contributes only the parameters its - * own cast placeholders state. + * column's name. A placeholder inside the {@code = ANY(p)} form takes a + * list of the compared column's values. Every other operand contributes + * only the parameters its own cast placeholders state. */ private void resolveParameterComparison( ComparisonOperator comparison, @@ -1583,6 +1584,15 @@ private void resolveParameterComparison( return; } + if (comparison instanceof EqualsTo && left instanceof net.sf.jsqlparser.schema.Column column) { + Expression listArgument = anyListArgument(right, placeholders); + + if (listArgument != null) { + resolveAnyListParameter(listArgument, column, sources, placeholders); + return; + } + } + if (isColumnTypedComparison(comparison)) { if (left instanceof net.sf.jsqlparser.schema.Column column) { CastPlaceholder cast = placeholders.ofCast(right); @@ -1630,6 +1640,113 @@ private boolean isColumnTypedComparison(ComparisonOperator comparison) { || comparison instanceof MinorThanEquals; } + /** + * The argument of the {@code ANY(p)} operand whose parameter is the list a + * compared column's value is looked up in, and {@code null} when the + * expression is not that operand. The operand is an unquoted, unqualified, + * case-insensitive {@code ANY} with no modifier and with exactly one + * argument that is itself one parameter's value, which is a placeholder or + * a cast of one. + * + *

Every other spelling, modifier, and argument, such as a quoted + * {@code "ANY"}, {@code pg_catalog.any}, an array constructor, and a + * literal, is another expression, which binds no list here. + */ + private Expression anyListArgument(Expression expression, Placeholders placeholders) { + if (!(expression instanceof Function function)) { + return null; + } + + List name = function.getMultipartName(); + + if (name == null || name.size() != 1 || !name.getFirst().equalsIgnoreCase("any")) { + return null; + } + + if (function.isDistinct() || function.getOrderByElements() != null) { + return null; + } + + ExpressionList arguments = function.getParameters(); + + if (arguments == null || arguments.size() != 1) { + return null; + } + + Expression argument = arguments.getFirst(); + + return bindsValue(argument, placeholders) + ? argument + : null; + } + + /** + * Resolves the list parameter of {@code = ANY(p)}, which is named + * after the compared column and is an array of that column's type, so that + * the generated method takes one list and the runtime binds it as one + * server array. A null list and an empty list match no row, as PostgreSQL + * compares a value with no element of either. + * + *

A cast argument states its own type, as a cast placeholder does + * anywhere else, and keeps the column's name, so only the column is + * resolved for it. + */ + private void resolveAnyListParameter( + Expression argument, + net.sf.jsqlparser.schema.Column column, + List sources, + Placeholders placeholders + ) { + dev.sqlcj.schema.Column schemaColumn = resolveColumn(column, sources).column(); + Placeholder placeholder = placeholders.of(argument); + + if (placeholder == null) { + addCastParameter( + placeholders.ofCast(argument), + schemaColumn.name(), + placeholders + ); + return; + } + + ColumnType type = requireSupportedType(schemaColumn); + + requireArrayElementColumn(schemaColumn); + + addParameter( + placeholder, + schemaColumn.name(), + type, + schemaColumn.enumType(), + true, + schemaColumn.blankPadded(), + placeholders + ); + } + + /** + * Requires a column an array of whose values sqlcj binds, because the list + * parameter of {@code = ANY} takes the compared column's type as its + * element type. An array column's own values are not elements of its type, + * and the element types the schema maps no array of have no array binding. + */ + private dev.sqlcj.schema.Column requireArrayElementColumn(dev.sqlcj.schema.Column column) { + if (column.array() || !columnTypeMapping.isArrayElementType(column.type())) { + throw new UnsupportedOperationException( + ("An = ANY placeholder requires a non-array column of a type other than BYTEA, " + + "JSON, and JSONB, but %s is %s.") + .formatted( + column.name(), + column.array() + ? column.type() + "[]" + : column.type() + ) + ); + } + + return column; + } + /** * Resolves one value of a clause that names its parameter after a column: a * placeholder typed from the column, a cast placeholder that states its own diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/ColumnTypeMapping.java b/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/ColumnTypeMapping.java index e67fadc..a1b7a72 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/ColumnTypeMapping.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/ColumnTypeMapping.java @@ -126,8 +126,11 @@ private boolean isBlankPadded(String typeName) { * {@code BYTEA}, {@code JSON}, and {@code JSONB} arrays are recorded with * their declared type instead, because their elements are bound and read as * text or bytes rather than as a value of a mapped element type. + * + *

Query analysis asks the same question of a column whose type an array + * parameter carries, so the rule is stated once here. */ - private boolean isArrayElementType(ColumnType type) { + public boolean isArrayElementType(ColumnType type) { return type != ColumnType.BYTEA && type != ColumnType.JSON && type != ColumnType.JSONB; 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 14d55a3..4137a8f 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -90,6 +90,32 @@ class QueryAnalyzerTest { List.of(new EnumType("stage_setting", List.of("indoor", "outdoor"))) ); + /** + * A schema whose {@code users} table carries two columns of one type, a + * blank-padded character column, an enum column, and a column of every kind + * an array parameter cannot carry, for the list predicate. + */ + private static final Schema anyListSchema = new Schema( + List.of( + new Table( + "users", + List.of( + new Column("id", ColumnType.BIGINT, false), + new Column("parent_id", ColumnType.BIGINT, true), + new Column("name", ColumnType.VARCHAR, true), + new Column("code", ColumnType.VARCHAR, true, null, null, false, true), + new Column("role", ColumnType.ENUM, true, null, "user_role"), + new Column("tags", ColumnType.VARCHAR, true, null, null, true), + new Column("avatar", ColumnType.BYTEA, true), + new Column("payload", ColumnType.JSON, true), + new Column("document", ColumnType.JSONB, true) + ), + List.of() + ) + ), + List.of(new EnumType("user_role", List.of("admin", "member"))) + ); + /** A schema with both text column types, for the text predicate forms. */ private static final Schema predicateSchema = new Schema( List.of( @@ -3794,6 +3820,7 @@ void shouldKeepPlaceholderTextThatIsNotAParameter() { "SELECT id FROM users WHERE tags = $1|MANY", "SELECT id FROM users WHERE tags IN ($1)|MANY", "SELECT id FROM users WHERE tags LIKE $1|MANY", + "SELECT id FROM users WHERE tags = ANY($1)|MANY", "INSERT INTO users (tags) VALUES ($1)|EXEC", "UPDATE users SET tags = $1|EXEC", "DELETE FROM users WHERE id = $1 RETURNING tags|MANY", @@ -4747,4 +4774,269 @@ void shouldRejectAnUnknownConflictColumn(String sql) { exception.getMessage() ); } + + /** + * A placeholder that is the argument of {@code = ANY} is one list of the + * compared column's type, named after that column and bound at its own + * {@code ?} position, however the operator and the column are spelled. The + * rest of the statement, including the operator, reaches JDBC as written. + */ + @ParameterizedTest + @ValueSource( + strings = { + "SELECT id FROM users WHERE id = ANY($1)", + "SELECT id FROM users WHERE id = any($1)", + "SELECT u.id FROM users u WHERE u.id = ANY($1)" + } + ) + void shouldResolveListParameterOfAnyFromItsComparedColumn(String sql) { + QueryModel model = analyzer.analyze( + new Query("ListUsers", QueryType.MANY, sql), + parser.parse(sql), + anyListSchema + ); + + assertEquals( + List.of(new QueryParameter(1, "id", ColumnType.BIGINT, null, true)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + assertEquals(sql.replace("$1", "?"), model.executableSql()); + } + + /** + * A named list placeholder keeps its own name, and one name used by two + * list predicates is one parameter bound at both of its positions. + */ + @Test + void shouldResolveNamedListParameterOfAnyAtEveryOccurrence() { + String sql = "SELECT id FROM users WHERE id = ANY(:ids) OR parent_id = ANY(:ids)"; + + QueryModel model = analyzer.analyze( + new Query("ListUsers", QueryType.MANY, sql), + parser.parse(sql), + anyListSchema + ); + + assertEquals( + List.of(new QueryParameter(1, "ids", ColumnType.BIGINT, null, true)), + model.parameters() + ); + + assertEquals(List.of(1, 1), model.bindingParameterIndexes()); + } + + /** + * A list parameter carries its column's enum type and blank-padded + * spelling, so that generation binds the labels of an enum list and the + * {@code bpchar} elements of a blank-padded character list. + */ + @Test + void shouldCarryTheEnumTypeAndBlankPaddingOfAListParameterOfAny() { + String sql = "SELECT id FROM users WHERE role = ANY($1) OR code = ANY($2)"; + + QueryModel model = analyzer.analyze( + new Query("ListUsers", QueryType.MANY, sql), + parser.parse(sql), + anyListSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "role", ColumnType.ENUM, "user_role", true), + new QueryParameter(2, "code", ColumnType.VARCHAR, null, true, true) + ), + model.parameters() + ); + } + + /** + * The {@code WHERE} clause of a write analyzes a list predicate as a read + * does, so the written value is bound before the list it selects rows by. + */ + @Test + void shouldResolveListParameterOfAnyInWrites() { + String delete = "DELETE FROM users WHERE id = ANY($1)"; + + QueryModel deleted = analyzer.analyze( + new Query("DeleteUsers", QueryType.EXEC, delete), + parser.parse(delete), + anyListSchema + ); + + assertEquals( + List.of(new QueryParameter(1, "id", ColumnType.BIGINT, null, true)), + deleted.parameters() + ); + + assertEquals(List.of(1), deleted.bindingParameterIndexes()); + + String update = "UPDATE users SET name = $1 WHERE id = ANY($2)"; + + QueryModel updated = analyzer.analyze( + new Query("RenameUsers", QueryType.EXEC, update), + parser.parse(update), + anyListSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "name", ColumnType.VARCHAR), + new QueryParameter(2, "id", ColumnType.BIGINT, null, true) + ), + updated.parameters() + ); + + assertEquals(List.of(1, 2), updated.bindingParameterIndexes()); + } + + /** + * A cast that is the direct argument of {@code = ANY} states its own type + * and keeps the compared column's name, as a cast beside a compared column + * does, while a named one keeps its own name. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "SELECT id FROM users WHERE id = ANY($1::bigint[])|id", + "SELECT id FROM users WHERE id = ANY(CAST($1 AS bigint[]))|id", + "SELECT id FROM users WHERE id = ANY(:ids::bigint[])|ids" + } + ) + void shouldNameTheCastArgumentOfAnyAfterItsComparedColumn(String sql, String name) { + QueryModel model = analyzer.analyze( + new Query("ListUsers", QueryType.MANY, sql), + parser.parse(sql), + anyListSchema + ); + + assertEquals( + List.of(new QueryParameter(1, name, ColumnType.BIGINT, null, true)), + model.parameters() + ); + } + + /** + * A cast placeholder under an operator that is not the list predicate is + * named after its own placeholder, as any other unnamed cast is. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "SELECT id FROM users WHERE id <> ANY($1::bigint[])|BIGINT", + "SELECT id FROM users WHERE tags && $1::varchar[]|VARCHAR" + } + ) + void shouldNameACastArgumentOutsideTheListPredicateAfterItsPlaceholder( + String sql, + ColumnType type + ) { + QueryModel model = analyzer.analyze( + new Query("ListUsers", QueryType.MANY, sql), + parser.parse(sql), + anyListSchema + ); + + assertEquals( + List.of(new QueryParameter(1, "param1", type, null, true)), + model.parameters() + ); + } + + /** + * A list parameter takes the compared column's type as its element type, so + * a column sqlcj binds no array of is rejected: an array column, whose own + * values are not elements of its type, and a {@code BYTEA}, {@code JSON}, + * or {@code JSONB} column, which has no array element binding. + */ + @ParameterizedTest + @CsvSource( + { + "tags, VARCHAR[]", + "avatar, BYTEA", + "payload, JSON", + "document, JSONB" + } + ) + void shouldRejectAListParameterOfAColumnWithoutAnArrayBinding( + String column, + String type + ) { + String sql = "SELECT id FROM users WHERE %s = ANY($1)".formatted(column); + + Query query = new Query("ListUsers", QueryType.MANY, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, anyListSchema) + ); + + assertEquals( + ("An = ANY placeholder requires a non-array column of a type other than BYTEA, " + + "JSON, and JSONB, but %s is %s.").formatted(column, type), + exception.getMessage() + ); + } + + /** + * Only the exact list-predicate shape binds a list, so another operator, + * another operand order, another quantifier, a modifier, and an argument + * that is not one placeholder leave their placeholders unaccounted. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "SELECT id FROM users WHERE id <> ANY($1)|[1]", + "SELECT id FROM users WHERE id = SOME($1)|[1]", + "SELECT id FROM users WHERE ANY($1) = id|[1]", + "SELECT id FROM users WHERE id = ANY(ARRAY[$1, $2])|[1, 2]", + "SELECT id FROM users WHERE id = ANY(DISTINCT $1)|[1]", + "SELECT id FROM users WHERE id = ANY($1 ORDER BY id)|[1]" + } + ) + void shouldRejectAPlaceholderOutsideTheListPredicateShape(String sql, String compiled) { + Query query = new Query("ListUsers", QueryType.MANY, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, anyListSchema) + ); + + assertEquals( + "SQL placeholders %s are not the analyzed parameters []; ".formatted(compiled) + + "a placeholder is in an unsupported location", + exception.getMessage() + ); + } + + /** + * A list and a value of its element type are two Java types, so one name + * used as both has no single type and is rejected. + */ + @Test + void shouldRejectANameUsedAsBothAListAndItsElement() { + String sql = "SELECT id FROM users WHERE id = ANY(:ids) OR id = :ids"; + + Query query = new Query("ListUsers", QueryType.MANY, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, anyListSchema) + ); + + assertEquals( + "Placeholder :ids has conflicting types: List and Long", + exception.getMessage() + ); + } } 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 7ca7b8b..ceaa651 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -774,6 +774,110 @@ INSERT INTO users (id, code, name) } } + /** + * Covers the list predicate end to end: one {@code List} argument is bound + * as one server array and PostgreSQL returns the rows whose id the list + * contains, in the queried order, while a null list and an empty list + * contain no id and match no row. + */ + @Test + void shouldExecuteGeneratedListPredicateAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: ListUsersByIds :many + SELECT id, name + FROM users + WHERE id = ANY($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 listByIds = repository.getClass().getMethod( + "listUsersByIds", + List.class + ); + + List noIds = null; + + assertEquals(List.of(), names(listByIds.invoke(repository, noIds))); + assertEquals(List.of(), names(listByIds.invoke(repository, List.of()))); + + assertEquals( + List.of("Bob"), + names(listByIds.invoke(repository, List.of(2L))) + ); + + assertEquals( + List.of("Alice", "Cara"), + names(listByIds.invoke(repository, List.of(3L, 1L))) + ); + } + } + + /** + * A list predicate over an enum column binds the labels of its generated + * constants as one server array of the declared enum type, so PostgreSQL + * returns the rows whose value the list contains. + */ + @Test + void shouldExecuteGeneratedEnumListPredicateAgainstPostgres() throws Exception { + execute(STAGE_EVENT_SCHEMA); + + Path classesDirectory = generateAndCompile( + STAGE_EVENT_SCHEMA, + """ + -- name: ListStageEventsBySettings :many + SELECT id, setting, title + FROM stage_events + WHERE setting = ANY($1) + ORDER BY id; + """ + ); + + execute(""" + INSERT INTO stage_events (id, setting, title) + VALUES + (1, 'indoor', 'Indoor Stage'), + (2, 'covered', 'Covered Stage'), + (3, 'outdoor', 'Outdoor Stage') + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object[] constants = Class + .forName("generated.StageSetting", true, classLoader) + .getEnumConstants(); + + Object repository = newRepository(classLoader); + + Method listBySettings = repository.getClass().getMethod( + "listStageEventsBySettings", + List.class + ); + + Object rows = listBySettings.invoke( + repository, + List.of(constants[0], constants[2]) + ); + + List titles = new ArrayList<>(); + + for (Object row : (List) rows) { + titles.add(component(row, "title")); + } + + assertEquals(List.of("Indoor Stage", "Outdoor Stage"), titles); + } + } + /** * Covers pagination end to end: a {@code LIMIT ... OFFSET ...} page and the * same page written as {@code OFFSET ... LIMIT ...} are generated, diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java index 0247c4d..20847a1 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -3144,6 +3144,52 @@ name VARCHAR(255), ); } + /** + * A list predicate generates one {@code List} method parameter of the + * compared column's element type, bound as one server array, in compilable + * Java. + */ + @Test + void shouldGenerateCompilableRepositoryForAListPredicate() throws IOException { + generateAndCompile( + """ + CREATE TABLE users + ( + id BIGINT NOT NULL, + name VARCHAR(255) + ); + """, + """ + -- name: ListUsersByIds :many + SELECT id, name + FROM users + WHERE id = ANY($1) + ORDER BY id; + """ + ); + + String repository = Files.readString( + tempDir + .resolve("generated") + .resolve("generated") + .resolve("UsersRepository.java") + ); + + assertTrue( + repository.contains( + "public List listUsersByIds(List id)" + ) + ); + + assertTrue(repository.contains("WHERE id = ANY(?)")); + + assertTrue( + repository.contains( + "java.util.Arrays.asList(new dev.sqlcj.runtime.SqlArray(\"int8\", id))" + ) + ); + } + /** Generates the one repository of the {@code Users} group. */ private Path generateUsersRepository( List schema, From 454cd3555fa8de6f6b4b00ca237b9343570df39a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sun, 4 Oct 2026 23:06:57 +0300 Subject: [PATCH 2/2] docs: document = ANY list predicates --- docs/postgresql.md | 11 ++++++- docs/queries.md | 77 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/docs/postgresql.md b/docs/postgresql.md index 012fd0d..be4155b 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -302,7 +302,11 @@ generates `List tags` and `List past`. index. - A `LIKE`/`ILIKE` pattern placeholder still requires a scalar `VARCHAR` or `TEXT` column, so an array column is rejected there. -- No array operator or function, such as `= ANY` or `&&`, is analyzed. +- The `= ANY` list predicate is the one analyzed array operator: a placeholder + written as ` = ANY($1)` binds a list of the compared column's type, + which must be a non-array column of an element type above or of a declared + enum type. See [Queries](queries.md#predicates) for the shape and its + rejections. No other array operator or function, such as `&&`, is analyzed. An array argument is passed to the executor as `new dev.sqlcj.runtime.SqlArray("", )`, written out in @@ -640,6 +644,11 @@ Array types: `QueryAnalyzerTest.shouldRejectLikeOnAnArrayColumn` cover the rejections. `QueryAnalyzerTest.shouldCarryTheBlankPaddedSpellingOfAnArrayParameter` covers the spelling a character array parameter carries. + `QueryAnalyzerTest.shouldResolveListParameterOfAnyFromItsComparedColumn` and + `QueryAnalyzerTest.shouldRejectAListParameterOfAColumnWithoutAnArrayBinding` + cover the list a `= ANY` placeholder binds and the columns it rejects, and + `PostgresIntegrationTest.shouldExecuteGeneratedListPredicateAgainstPostgres` + executes it. - `JavaCodeGeneratorTest.shouldBindAndReadArrayColumnsPerElementType` covers the generated `List` type, the `SqlArray` argument, and the `getList` read of every element type; diff --git a/docs/queries.md b/docs/queries.md index 4492f7b..e9a33bb 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -186,7 +186,9 @@ A `WHERE` clause may combine: such as `name LIKE $1`, - `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`. + `code BETWEEN $1 AND $2`, +- `= ANY` between a direct column and one `$N` list placeholder, such as + `id = ANY($1)`. 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 @@ -212,6 +214,24 @@ its 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. +` = ANY()` is the id-list read. The placeholder is one +whole list of the compared column's type, named after that column, so +`WHERE id = ANY($1)` takes a `List id` and the runtime binds the list as +one PostgreSQL array at one `?` position. A null list and an empty list contain +no value, so each matches no row. A named list keeps its own name, and one name +used by two list predicates, as in +`id = ANY(:ids) OR parent_id = ANY(:ids)`, is one parameter bound at both +positions. + +The compared column must be a non-array column of a declared enum type or of a +mapped type other than `BYTEA`, `JSON`, and `JSONB`, which are the element +types sqlcj binds no array of; any other column is rejected. Only this exact +shape is analyzed: the operator is `=`, the column is its left operand, and +`ANY` is written unquoted and unqualified with exactly one placeholder +argument. `id <> ANY($1)`, `id = SOME($1)`, `ANY($1) = id`, and +`id = ANY(ARRAY[$1, $2])` are therefore rejected as unanalyzed placeholder +locations, and sqlcj expands no `IN` list of its own. + A comparison that binds no placeholder, such as `active = TRUE`, contributes no generated parameter and reaches the database as written. @@ -244,8 +264,13 @@ to an uncast pattern: `name NOT LIKE $1::text`, `name SIMILAR TO $1::text`, and `name LIKE $1::text ESCAPE '!'` are accepted and typed `text`, whatever the tested column's type is. Such a pattern is named after its placeholder rather than after the tested column, so an indexed one is `param`, as in `param1` -for `$1`. [Parameters](#parameters) states the accepted cast types and the name -a cast placeholder takes. +for `$1`. A cast that is the direct argument of an analyzed `= ANY` list, as in +`id = ANY($1::bigint[])`, `id = ANY(CAST($1 AS bigint[]))`, or +`id = ANY(:ids::bigint[])`, states its own type and keeps the compared column's +name, while a cast under any other operator, such as `id <> ANY($1::bigint[])` +or `tags && $1::varchar[]`, is named `param`. +[Parameters](#parameters) states the accepted cast types and the name a cast +placeholder takes. ### Ordering @@ -457,8 +482,9 @@ string literal, a quoted identifier, or a comment is not a parameter. - A named cast placeholder keeps its own name. An indexed cast placeholder keeps the name of the column whose value it is — a compared column, the tested column of `IN` or `BETWEEN`, the tested column of a plain `LIKE` or - `ILIKE` pattern, which is the shape that accepts an uncast pattern, or an - inserted or assigned column — so `name = $1::text` names `name` as + `ILIKE` pattern, which is the shape that accepts an uncast pattern, the + compared column of an analyzed `= ANY` list, or an inserted or assigned + column — so `name = $1::text` names `name` as `name = $1` does. Every other indexed cast placeholder is named `param` after its own index, as in `param1` for `$1`, and colliding names take the usual numeric suffix. @@ -760,6 +786,16 @@ name, and its header line. as `SIMILAR TO`, carries an `ESCAPE` clause or a `BINARY` modifier, tests a non-text column, or stands as the tested value. These restrict the uncast pattern placeholder; a cast pattern states its own type. +- A `= ANY` list placeholder whose compared column is an array column or a + `BYTEA`, `JSON`, or `JSONB` column, because sqlcj binds no array of those + element types. Every form outside the analyzed shape — another operator such + as `id <> ANY($1)`, another quantifier such as `id = SOME($1)`, a reversed + `ANY($1) = id`, a quoted or qualified `"ANY"($1)` or `pg_catalog.any($1)`, + a modifier such as `ANY(DISTINCT $1)`, more than one argument, and an + argument that is not one placeholder, such as `id = ANY(ARRAY[$1, $2])` — + keeps the unanalyzed-placeholder rejection. A + cast does not widen them: `id <> ANY($1::bigint[])` is accepted only as any + other cast placeholder is, named after its own index. - Anonymous `?` placeholders, `$N` and `:name` placeholders mixed in one query, a qualified name such as `:a.b`, a quoted name such as `:"x"`, an `&name` placeholder, each of them also as the operand of a cast, such as @@ -790,17 +826,18 @@ them: - `DISTINCT`, `GROUP BY`, and `HAVING`, - predicate forms other than the listed comparisons, `AND`/`OR`, fixed `IN` - 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, + lists, pattern placeholders, null tests, ranges, and list predicates, such as + `LIKE` with a literal pattern, a range whose bounds are both literal such as + `id BETWEEN 1 AND 10`, `IN` with a subquery, or `= ANY` over something other + than a placeholder, such as `id = ANY('{1,2}')`, - common table expressions, and subqueries outside the `FROM` item, - `UPDATE ... FROM` and `DELETE ... USING` on a non-returning `:exec` write, - the unique index an accepted `ON CONFLICT` target matches, and the column references of an `EXCLUDED` value that is not exactly `EXCLUDED.column`. -sqlcj itself provides no macros, array operators such as `= ANY`, dynamic `IN` -expansion, or query-building API. An array parameter is one whole list bound at -one placeholder, not a placeholder list. +sqlcj itself provides no macros, dynamic `IN` expansion, or query-building API. +The `= ANY` list predicate is the only analyzed array operator. An array +parameter is one whole list bound at one placeholder, not a placeholder list. Unsupported schema input and unsupported column types are listed in [PostgreSQL Support](postgresql.md#unsupported-types-and-ddl). @@ -893,6 +930,24 @@ Reads: `PostgresIntegrationTest.shouldExecuteGeneratedRangePredicatesAgainstPostgres` executes a `BETWEEN` read and a `NOT BETWEEN` read whose bounds use out-of-order placeholder indexes against PostgreSQL 16. +- `QueryAnalyzerTest.shouldResolveListParameterOfAnyFromItsComparedColumn`, + `QueryAnalyzerTest.shouldResolveNamedListParameterOfAnyAtEveryOccurrence`, + `QueryAnalyzerTest.shouldCarryTheEnumTypeAndBlankPaddingOfAListParameterOfAny`, + `QueryAnalyzerTest.shouldResolveListParameterOfAnyInWrites`, + `QueryAnalyzerTest.shouldNameTheCastArgumentOfAnyAfterItsComparedColumn`, + `QueryAnalyzerTest.shouldNameACastArgumentOutsideTheListPredicateAfterItsPlaceholder`, + `QueryAnalyzerTest.shouldRejectAListParameterOfAColumnWithoutAnArrayBinding`, + `QueryAnalyzerTest.shouldRejectAPlaceholderOutsideTheListPredicateShape`, and + `QueryAnalyzerTest.shouldRejectANameUsedAsBothAListAndItsElement` cover the + list predicate, its naming, its cast argument, and the forms that stay + rejected, + `SqlcjCompilerIntegrationTest.shouldGenerateCompilableRepositoryForAListPredicate` + compiles its `List` method parameter and its array binding, and + `PostgresIntegrationTest.shouldExecuteGeneratedListPredicateAgainstPostgres` + and + `PostgresIntegrationTest.shouldExecuteGeneratedEnumListPredicateAgainstPostgres` + execute an id list of no, one, and several ids and an enum-column list + against PostgreSQL 16. - `QueryAnalyzerTest.shouldResolvePaginationParametersAfterPredicateParameters`, `QueryAnalyzerTest.shouldResolvePaginationParametersInTextualBindingOrder`, `QueryAnalyzerTest.shouldResolveOffsetParameterBesideUnanalyzedRowCount`,