From 10284d37f14d04c6b949118fd1fe2e8e2c70ba1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Thu, 1 Oct 2026 18:45:45 +0300 Subject: [PATCH 1/2] feat(analysis): accept write values that bind no placeholder --- .../dev/sqlcj/analysis/QueryAnalyzer.java | 45 ++- .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 302 ++++++++++++++++++ .../compiler/PostgresIntegrationTest.java | 92 ++++++ 3 files changed, 415 insertions(+), 24 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 5e33ade..f063ea3 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -511,7 +511,9 @@ private void requireNoCommonTableExpressions(List withItems) { /** * Resolves the {@code INSERT} parameters by pairing the explicit column - * list with the single values row, which is also their textual order. + * list with the single values row, which is also their textual order. A + * value that is not a placeholder binds nothing and reaches the database as + * written, so only its target column must exist. */ private void resolveInsertParameters( Insert insert, @@ -533,20 +535,15 @@ private void resolveInsertParameters( } for (int index = 0; index < columns.size(); index++) { + String columnName = columns.get(index).getUnquotedColumnName(); Placeholder placeholder = placeholders.of(values.get(index)); if (placeholder == null) { - throw new UnsupportedOperationException( - "INSERT values must be indexed placeholders." - ); + findColumn(table, columnName); + continue; } - addParameter( - placeholder, - columns.get(index).getUnquotedColumnName(), - table, - placeholders - ); + addParameter(placeholder, columnName, table, placeholders); } } @@ -564,7 +561,9 @@ private ParenthesedExpressionList resolveInsertValues(Insert insert) { /** * Resolves the {@code UPDATE} assignment parameters in source order, which - * precedes any parameter in the {@code WHERE} expression. + * precedes any parameter in the {@code WHERE} expression. An assigned value + * that is not a placeholder binds nothing and reaches the database as + * written, so only its target column must exist. */ private void resolveUpdateSetParameters( Update update, @@ -572,23 +571,21 @@ private void resolveUpdateSetParameters( Placeholders placeholders ) { for (UpdateSet updateSet : update.getUpdateSets()) { - Placeholder placeholder = updateSet.getColumns().size() == 1 - && updateSet.getValues().size() == 1 - ? placeholders.of(updateSet.getValue(0)) - : null; - - if (placeholder == null) { + if (updateSet.getColumns().size() != 1 || updateSet.getValues().size() != 1) { throw new UnsupportedOperationException( - "UPDATE assignments must set one column to an indexed placeholder." + "UPDATE assignments must set one column at a time." ); } - addParameter( - placeholder, - updateSet.getColumn(0).getUnquotedColumnName(), - table, - placeholders - ); + String columnName = updateSet.getColumn(0).getUnquotedColumnName(); + Placeholder placeholder = placeholders.of(updateSet.getValue(0)); + + if (placeholder == null) { + findColumn(table, columnName); + continue; + } + + addParameter(placeholder, columnName, table, placeholders); } } 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 2e0dd3e..78f27c8 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -123,6 +123,28 @@ class QueryAnalyzerTest { ) ); + /** + * A schema whose {@code users} table carries the columns a non-binding + * write value targets beside the bound ones. + */ + private static final Schema writeSchema = new Schema( + List.of( + new Table( + "users", + List.of( + new Column("id", ColumnType.BIGINT, false), + new Column("name", ColumnType.VARCHAR, true), + new Column("nickname", ColumnType.VARCHAR, true), + new Column("bio", ColumnType.TEXT, true), + new Column("active", ColumnType.BOOLEAN, true), + new Column("version", ColumnType.INTEGER, false), + new Column("updated_at", ColumnType.TIMESTAMP, true) + ), + List.of() + ) + ) + ); + private static final Schema joinSchema = new Schema( List.of( new Table( @@ -3570,4 +3592,284 @@ void shouldAcceptARepeatedIndexOfOneEnumType() { assertEquals(List.of(1, 1), model.bindingParameterIndexes()); } + + /** + * An {@code INSERT} value that binds no placeholder contributes no + * parameter and reaches the database as written, while the placeholders + * beside it keep their own numbering and textual binding order. + */ + @Test + void shouldAnalyzeInsertWithNonBindingValues() { + String sql = "INSERT INTO users (version, name, id) VALUES ($2, now(), $1)"; + + QueryModel model = analyzer.analyze( + new Query("CreateUser", QueryType.EXEC, sql), + parser.parse(sql), + writeSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "version", ColumnType.INTEGER) + ), + model.parameters() + ); + + assertEquals(List.of(2, 1), model.bindingParameterIndexes()); + + assertEquals( + "INSERT INTO users (version, name, id) VALUES (?, now(), ?)", + model.executableSql() + ); + } + + /** The same insert written with named placeholders. */ + @Test + void shouldAnalyzeNamedInsertWithNonBindingValues() { + String sql = "INSERT INTO users (version, name, id) VALUES (:b, now(), :a)"; + + QueryModel model = analyzer.analyze( + new Query("CreateUser", QueryType.EXEC, sql), + parser.parse(sql), + writeSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "b", ColumnType.INTEGER), + new QueryParameter(2, "a", ColumnType.BIGINT) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2), model.bindingParameterIndexes()); + + assertEquals( + "INSERT INTO users (version, name, id) VALUES (?, now(), ?)", + model.executableSql() + ); + } + + /** + * A returning insert accepts non-binding values without changing the + * columns it returns. + */ + @Test + void shouldAnalyzeReturningInsertWithNonBindingValues() { + String sql = """ + INSERT INTO users (id, version, name, updated_at) + VALUES ($1, DEFAULT, 'anon', now()) + RETURNING id, name"""; + + QueryModel model = analyzer.analyze( + new Query("CreateUser", QueryType.ONE, sql), + parser.parse(sql), + writeSchema + ); + + assertEquals( + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true) + ), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + + assertEquals( + """ + INSERT INTO users (id, version, name, updated_at) + VALUES (?, DEFAULT, 'anon', now()) + RETURNING id, name""", + model.executableSql() + ); + } + + /** + * Non-binding assignments of every listed form are accepted, and the + * assignment placeholder still binds before the predicate placeholder. + */ + @Test + void shouldAnalyzeUpdateWithNonBindingAssignments() { + String sql = """ + UPDATE users + SET nickname = $2, + version = version + 1, + updated_at = now(), + name = 'anon', + active = DEFAULT, + bio = NULL + WHERE id = $1"""; + + QueryModel model = analyzer.analyze( + new Query("TouchUser", QueryType.EXEC, sql), + parser.parse(sql), + writeSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "nickname", ColumnType.VARCHAR) + ), + model.parameters() + ); + + assertEquals(List.of(2, 1), model.bindingParameterIndexes()); + + assertEquals( + """ + UPDATE users + SET nickname = ?, + version = version + 1, + updated_at = now(), + name = 'anon', + active = DEFAULT, + bio = NULL + WHERE id = ?""", + model.executableSql() + ); + } + + /** A write whose every value binds nothing has no parameter at all. */ + @ParameterizedTest + @ValueSource( + strings = { + "INSERT INTO users (id, version, updated_at) VALUES (DEFAULT, 1, now())", + "UPDATE users SET version = version + 1, updated_at = now()" + } + ) + void shouldAnalyzeWriteWithoutAnyPlaceholder(String sql) { + QueryModel model = analyzer.analyze( + new Query("TouchUsers", QueryType.EXEC, sql), + parser.parse(sql), + writeSchema + ); + + assertTrue(model.parameters().isEmpty()); + assertTrue(model.bindingParameterIndexes().isEmpty()); + assertEquals(sql, model.executableSql()); + } + + /** + * Nothing binds a non-binding value, so its target column's type need not + * be mapped. + */ + @ParameterizedTest + @ValueSource( + strings = { + "INSERT INTO users (id, tags) VALUES ($1, DEFAULT)", + "UPDATE users SET tags = NULL WHERE id = $1" + } + ) + void shouldAnalyzeNonBindingValueOnAnUnsupportedTypeColumn(String sql) { + QueryModel model = analyzer.analyze( + new Query("ClearTags", QueryType.EXEC, sql), + parser.parse(sql), + unsupportedTypeSchema + ); + + assertEquals( + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + } + + /** The target column of a non-binding value must exist in the table. */ + @ParameterizedTest + @ValueSource( + strings = { + "INSERT INTO users (id, missing) VALUES ($1, now())", + "UPDATE users SET missing = now() WHERE id = $1" + } + ) + void shouldRejectAnUnknownNonBindingTargetColumn(String sql) { + Query query = new Query("TouchUser", QueryType.EXEC, sql); + ParsedSql parsedSql = parser.parse(sql); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> analyzer.analyze(query, parsedSql, writeSchema) + ); + + assertEquals( + "Column not found in table users: missing", + exception.getMessage() + ); + } + + /** + * A write value that contains a placeholder without being one leaves that + * placeholder unanalyzed, which the placeholder accounting rejects. + */ + @Test + void shouldRejectAPlaceholderInsideAnInsertValue() { + String sql = "INSERT INTO users (id, name) VALUES ($1, $2 || 'x')"; + + Query query = new Query("CreateUser", QueryType.EXEC, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, writeSchema) + ); + + assertEquals( + "SQL placeholders [1, 2] are not the analyzed parameters [1];" + + " a placeholder is in an unsupported location", + exception.getMessage() + ); + } + + @Test + void shouldRejectAPlaceholderInsideAnUpdateAssignment() { + String sql = "UPDATE users SET name = :n || name WHERE id = :id"; + + Query query = new Query("TouchUser", QueryType.EXEC, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, writeSchema) + ); + + assertEquals( + "SQL placeholders [:n, :id] are not the analyzed parameters [:id];" + + " a placeholder is in an unsupported location", + exception.getMessage() + ); + } + + /** A row assignment and a multi-row insert stay rejected. */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "UPDATE users SET (name, active) = ('a', true)|" + + "UPDATE assignments must set one column at a time.", + "INSERT INTO users (id, name) VALUES (1, 'a'), (2, 'b')|" + + "INSERT requires a single VALUES row." + } + ) + void shouldRejectExcludedWriteValueForm(String sql, String message) { + Query query = new Query("TouchUsers", QueryType.EXEC, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, writeSchema) + ); + + assertEquals(message, 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 72b8999..6cb5f06 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -984,6 +984,98 @@ INSERT INTO users (id, code, name, birth_date, created_at, balance) } } + /** + * Covers write values that bind no placeholder: {@code DEFAULT}, a literal, + * {@code NULL}, {@code now()}, and {@code code + 1} contribute no method + * parameter and reach PostgreSQL as written, while the placeholders beside + * them keep their own numbering and textual binding order. + */ + @Test + void shouldExecuteGeneratedNonBindingWriteValuesAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: InsertUserWithDefaults :exec + INSERT INTO users (id, code, name, bio, serial_id, updated_at) + VALUES ($1, $2, 'Anonymous', NULL, DEFAULT, now()); + + -- name: CreateUser :one + INSERT INTO users (bio, id, code, name, updated_at, serial_id) + VALUES (:bio, :id, 1, 'Interleaved', now(), DEFAULT) + RETURNING id, code, name, bio, serial_id, updated_at; + + -- name: TouchUser :exec + UPDATE users + SET code = code + 1, + name = 'Touched', + updated_at = now(), + serial_id = DEFAULT, + bio = $2 + WHERE id = $1; + + -- name: GetUser :one + SELECT id, code, name, bio, serial_id, updated_at + FROM users + WHERE id = $1; + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Method insertWithDefaults = repository.getClass().getMethod( + "insertUserWithDefaults", + Long.class, + Integer.class + ); + + assertEquals(1, insertWithDefaults.invoke(repository, 1L, 42)); + + Method getUser = repository.getClass().getMethod("getUser", Long.class); + + Object inserted = getUser.invoke(repository, 1L); + + assertEquals(42, component(inserted, "code")); + assertEquals("Anonymous", component(inserted, "name")); + assertNull(component(inserted, "bio")); + assertNotNull(component(inserted, "serialId")); + assertInstanceOf(OffsetDateTime.class, component(inserted, "updatedAt")); + + Method createUser = repository.getClass().getMethod( + "createUser", + String.class, + Long.class + ); + + Object returned = createUser.invoke(repository, "first note", 2L); + + assertNotNull(returned); + + assertEquals( + List.of("id", "code", "name", "bio", "serialId", "updatedAt"), + recordComponentNames(returned) + ); + + assertEquals(2L, component(returned, "id")); + assertEquals(1, component(returned, "code")); + assertEquals("Interleaved", component(returned, "name")); + assertEquals("first note", component(returned, "bio")); + assertNotNull(component(returned, "serialId")); + assertInstanceOf(OffsetDateTime.class, component(returned, "updatedAt")); + + Method touchUser = repository.getClass().getMethod( + "touchUser", + Long.class, + String.class + ); + + assertEquals(1, touchUser.invoke(repository, 1L, "touched")); + + Object touched = getUser.invoke(repository, 1L); + + assertEquals(43, component(touched, "code")); + assertEquals("Touched", component(touched, "name")); + assertEquals("touched", component(touched, "bio")); + } + } + /** * Covers null parameter binding and null result reading for every nullable * column type of the schema snapshot: the row is written through the From c6fe5ffe3147a2bc8de6005361e3e83c833409c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Thu, 1 Oct 2026 18:45:54 +0300 Subject: [PATCH 2/2] docs: document write values that bind no placeholder --- docs/queries.md | 52 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/queries.md b/docs/queries.md index 58f601d..3966219 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -227,8 +227,8 @@ A write targets exactly one table of its entry's schema. | Statement | Accepted shape | | --- | --- | -| `INSERT` | an explicit column list and a single `VALUES` row whose values are all placeholders | -| `UPDATE` | `SET` assignments that each assign one direct column a single placeholder, with an optional `WHERE` using the read predicate forms | +| `INSERT` | an explicit column list and a single `VALUES` row, each value a placeholder or an expression that binds none | +| `UPDATE` | `SET` assignments that each assign one direct column a placeholder or an expression that binds none, with an optional `WHERE` using the read predicate forms | | `DELETE` | one target table with an optional `WHERE` using the read predicate forms | ```sql @@ -248,6 +248,30 @@ FROM authors WHERE id = $1; ``` +### Values that bind no placeholder + +An `INSERT` value or an `UPDATE` assignment that contains no placeholder — such +as `DEFAULT`, a literal, `NULL`, `now()`, or `version + 1` — contributes no +generated parameter and reaches the database exactly as written. Its target +column must exist in the written table, but nothing binds it, so its type need +not be one sqlcj maps. The placeholders beside it keep their own `$N` or `:name` +numbering and textual binding order: + +```sql +-- name: TouchAuthor :exec +UPDATE authors +SET bio = $2, + updated_at = now(), + version = version + 1 +WHERE id = $1; +``` + +`TouchAuthor` generates `touchAuthor(Long id, String bio)` and binds `(bio, id)`, +exactly as it would without the two non-binding assignments. A write whose every +value binds no placeholder, such as +`UPDATE authors SET version = version + 1`, generates a method without +parameters. + ### `RETURNING` A supported `INSERT`, `UPDATE`, or `DELETE` declared `:one`, `:optional`, or @@ -568,7 +592,8 @@ name, and its header line. tested value of a range such as `$1 BETWEEN id AND id`, a computed range bound such as `id BETWEEN $1 + 1 AND $2`, a computed pagination value such as `LIMIT $1 + 1` or `OFFSET $1 + 1`, a `LIMIT a, b` row count such as - `LIMIT 5, $1`, and a `FETCH FIRST $1 ROWS ONLY` clause, so dynamic `IN` + `LIMIT 5, $1`, a placeholder inside a write value such as + `COALESCE($2, bio)`, and a `FETCH FIRST $1 ROWS ONLY` clause, so dynamic `IN` expansion is 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 @@ -578,8 +603,9 @@ name, and its header line. placeholder, one name whose occurrences have conflicting types, and non-contiguous or non-positive placeholder indexes. - An `INSERT` without an explicit column list, with more than one `VALUES` row, - with a value that is not a placeholder, or built from a `SELECT`; and an - `UPDATE` assignment that is not a single placeholder. + or built from a `SELECT`; an `UPDATE` assignment that sets a column list, such + as `SET (name, active) = ('a', TRUE)`; and a written column that the table does + not declare. - An aliased, computed, qualified-wildcard, or unknown `RETURNING` item; `RETURNING` on `:exec`; and `ON CONFLICT`, `UPDATE ... FROM`, `DELETE ... USING`, or a common table expression in a returning write. @@ -745,6 +771,18 @@ Writes and `RETURNING`: `QueryAnalyzerTest.shouldRejectUnknownReturningColumn`, and `QueryAnalyzerTest.shouldRejectExcludedReturningWriteForm` cover the write shapes. +- `QueryAnalyzerTest.shouldAnalyzeInsertWithNonBindingValues`, + `QueryAnalyzerTest.shouldAnalyzeNamedInsertWithNonBindingValues`, + `QueryAnalyzerTest.shouldAnalyzeReturningInsertWithNonBindingValues`, + `QueryAnalyzerTest.shouldAnalyzeUpdateWithNonBindingAssignments`, + `QueryAnalyzerTest.shouldAnalyzeWriteWithoutAnyPlaceholder`, + `QueryAnalyzerTest.shouldAnalyzeNonBindingValueOnAnUnsupportedTypeColumn`, + `QueryAnalyzerTest.shouldRejectAnUnknownNonBindingTargetColumn`, + `QueryAnalyzerTest.shouldRejectAPlaceholderInsideAnInsertValue`, + `QueryAnalyzerTest.shouldRejectAPlaceholderInsideAnUpdateAssignment`, and + `QueryAnalyzerTest.shouldRejectExcludedWriteValueForm` cover the values that + bind no placeholder, their numbering and binding order, and the nearby + rejections. - `QueryAnalyzerTest.shouldResolveRowTableForReturningAllColumns` covers the returning writes that produce a complete table row. - `PostgresIntegrationTest.shouldExecuteGeneratedWriteAgainstPostgres`, @@ -754,6 +792,10 @@ Writes and `RETURNING`: and `PostgresIntegrationTest.shouldExecuteGeneratedDeleteReturningAgainstPostgres` execute them against PostgreSQL 16. +- `PostgresIntegrationTest.shouldExecuteGeneratedNonBindingWriteValuesAgainstPostgres` + executes `DEFAULT`, a literal, `NULL`, `now()`, and `code + 1` in an `:exec` + insert, a returning named insert, and an `:exec` update against + PostgreSQL 16. Parameters: