From e6851bba47df035c34fab170369570d44fd3bb9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sat, 3 Oct 2026 19:12:39 +0300 Subject: [PATCH 1/2] feat(analysis): accept ON CONFLICT upserts on inserts Analyze INSERT ... ON CONFLICT (columns) DO NOTHING and DO UPDATE SET on :exec and returning inserts. Target, assigned, and EXCLUDED columns resolve against the inserted table, and DO UPDATE placeholders are typed like UPDATE assignments and bound after the VALUES row in textual order. Constraint targets, expression, collated, or operator-class target elements, target predicates, and DO UPDATE ... WHERE are rejected on every insert. --- .../dev/sqlcj/analysis/QueryAnalyzer.java | 125 +++++++- .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 290 +++++++++++++++++- .../compiler/PostgresIntegrationTest.java | 154 ++++++++++ 3 files changed, 555 insertions(+), 14 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 3eb3dbe..1805f11 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -30,11 +30,16 @@ import net.sf.jsqlparser.expression.operators.relational.MinorThanEquals; import net.sf.jsqlparser.expression.operators.relational.NotEqualsTo; import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; +import net.sf.jsqlparser.schema.MultiPartName; import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.ReturningClause; import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.create.table.Index; import net.sf.jsqlparser.statement.delete.Delete; +import net.sf.jsqlparser.statement.insert.ConflictActionType; import net.sf.jsqlparser.statement.insert.Insert; +import net.sf.jsqlparser.statement.insert.InsertConflictAction; +import net.sf.jsqlparser.statement.insert.InsertConflictTarget; import net.sf.jsqlparser.statement.select.AllColumns; import net.sf.jsqlparser.statement.select.AllTableColumns; import net.sf.jsqlparser.statement.select.Join; @@ -88,6 +93,13 @@ public final class QueryAnalyzer { */ private static final String INDEXED_PARAMETER_NAME_PREFIX = "param"; + /** + * The qualifier an {@code ON CONFLICT DO UPDATE} assignment writes to read + * a column of the row the insert proposed, compared ignoring case as + * PostgreSQL resolves it. + */ + private static final String EXCLUDED_QUALIFIER = "EXCLUDED"; + /** * Resolves the Java type of parameter occurrence, which decides whether a * repeated placeholder index can share one generated parameter. @@ -325,6 +337,7 @@ private QueryModel analyzeInsert(Query query, ParsedSql parsedSql, Insert insert Placeholders placeholders = toPlaceholders(parsedSql, schema); resolveInsertParameters(insert, source.table(), placeholders); + resolveConflictParameters(insert, source.table(), placeholders); return toQueryModel( query, @@ -356,7 +369,7 @@ private QueryModel analyzeUpdate(Query query, ParsedSql parsedSql, Update update Placeholders placeholders = toPlaceholders(parsedSql, schema); - resolveUpdateSetParameters(update, source.table(), placeholders); + resolveUpdateSetParameters(update.getUpdateSets(), source.table(), placeholders); if (update.getWhere() != null) { resolveParameters(update.getWhere(), List.of(source), placeholders); @@ -545,12 +558,6 @@ private void requireSupportedReturningInsert(Insert insert) { ); } - if (insert.getConflictTarget() != null || insert.getConflictAction() != null) { - throw new UnsupportedOperationException( - "INSERT ... ON CONFLICT is not supported." - ); - } - requireNoCommonTableExpressions(insert.getWithItemsList()); } @@ -637,17 +644,18 @@ private ParenthesedExpressionList resolveInsertValues(Insert insert) { } /** - * Resolves the {@code UPDATE} assignment parameters in source order, which - * precedes any parameter in the {@code WHERE} expression. An assigned value - * that binds no placeholder reaches the database as written, so only its - * target column must exist. + * Resolves the assignment parameters of an {@code UPDATE} or of an + * {@code ON CONFLICT DO UPDATE} action in source order, which precedes any + * parameter in the {@code WHERE} expression. An assigned value that binds + * no placeholder reaches the database as written, so only its target + * column must exist. */ private void resolveUpdateSetParameters( - Update update, + List updateSets, dev.sqlcj.schema.Table table, Placeholders placeholders ) { - for (UpdateSet updateSet : update.getUpdateSets()) { + for (UpdateSet updateSet : updateSets) { if (updateSet.getColumns().size() != 1 || updateSet.getValues().size() != 1) { throw new UnsupportedOperationException( "UPDATE assignments must set one column at a time." @@ -664,6 +672,97 @@ private void resolveUpdateSetParameters( } } + /** + * Resolves the conflict clause of an insert, whose placeholders are written + * after the {@code VALUES} placeholders and therefore bind after them. Only + * a target of plain column names of the inserted table is modelled; + * PostgreSQL still decides which unique index that target matches. + */ + private void resolveConflictParameters( + Insert insert, + dev.sqlcj.schema.Table table, + Placeholders placeholders + ) { + InsertConflictTarget target = insert.getConflictTarget(); + InsertConflictAction action = insert.getConflictAction(); + + if (target == null && action == null) { + return; + } + + resolveConflictTarget(target, table); + + if (action == null || action.getConflictActionType() != ConflictActionType.DO_UPDATE) { + return; + } + + if (action.getWhereExpression() != null) { + throw new UnsupportedOperationException( + "ON CONFLICT DO UPDATE ... WHERE is not supported." + ); + } + + List updateSets = action.getUpdateSets(); + + resolveUpdateSetParameters(updateSets, table, placeholders); + resolveExcludedColumns(updateSets, table); + } + + /** + * Resolves the conflict target against the inserted table. A target this + * subset does not model — an absent one, a named constraint, or an element + * that is an expression or carries a collation or an operator class — + * decides the conflict by something the analyzed model does not describe. + */ + private void resolveConflictTarget( + InsertConflictTarget target, + dev.sqlcj.schema.Table table + ) { + boolean columnNames = target != null + && target.getConstraintName() == null + && target.getIndexElements().stream().noneMatch( + element -> element.isExpression() + || element.getCollation() != null + || element.getOperatorClass() != null + ); + + if (!columnNames) { + throw new UnsupportedOperationException( + "ON CONFLICT requires a target of column names, such as ON CONFLICT (id)." + ); + } + + if (target.getWhereExpression() != null) { + throw new UnsupportedOperationException( + "ON CONFLICT target predicates are not supported." + ); + } + + for (Index.ColumnParams element : target.getIndexElements()) { + findColumn(table, MultiPartName.unquote(element.getColumnName())); + } + } + + /** + * Resolves the column each {@code DO UPDATE} value that is exactly + * {@code EXCLUDED.column} reads from the row the insert proposed, which is + * a column of the inserted table. A value that merely contains such a + * reference is not analyzed, as no other computed write value is. + */ + private void resolveExcludedColumns( + List updateSets, + dev.sqlcj.schema.Table table + ) { + for (UpdateSet updateSet : updateSets) { + if ( + updateSet.getValue(0) instanceof net.sf.jsqlparser.schema.Column column + && EXCLUDED_QUALIFIER.equalsIgnoreCase(qualifier(column)) + ) { + findColumn(table, column.getUnquotedColumnName()); + } + } + } + /** * Builds the analyzed model from the parameter occurrences collected in * textual order, keeping that order for JDBC binding and exposing one 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 0b0f115..14d55a3 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -3724,7 +3724,7 @@ void shouldRejectUnknownReturningColumn() { delimiter = '|', value = { "INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING id" - + "|INSERT ... ON CONFLICT is not supported.", + + "|ON CONFLICT requires a target of column names, such as ON CONFLICT (id).", "INSERT INTO users (id) SELECT id FROM users RETURNING id" + "|RETURNING requires an INSERT with a single VALUES row.", "INSERT INTO users (id) VALUES ($1), ($2) RETURNING id" @@ -4459,4 +4459,292 @@ void shouldRejectExcludedWriteValueForm(String sql, String message) { assertEquals(message, exception.getMessage()); } + + /** + * A {@code DO UPDATE} action assigns its columns as an {@code UPDATE} does: + * {@code EXCLUDED.column} and a computed value bind nothing, while the + * assigned placeholder binds after the {@code VALUES} placeholders, in the + * textual order the query writes them. + */ + @Test + void shouldAnalyzeUpsertThatUpdatesOnConflict() { + String sql = """ + INSERT INTO users (id, name) + VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + active = $3, + version = users.version + 1, + updated_at = now()"""; + + QueryModel model = analyzer.analyze( + new Query("UpsertUser", QueryType.EXEC, sql), + parser.parse(sql), + writeSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR), + new QueryParameter(3, "active", ColumnType.BOOLEAN) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2, 3), model.bindingParameterIndexes()); + + assertEquals( + """ + INSERT INTO users (id, name) + VALUES (?, ?) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + active = ?, + version = users.version + 1, + updated_at = now()""", + model.executableSql() + ); + } + + /** The same upsert returns the row the action wrote. */ + @Test + void shouldAnalyzeReturningUpsertThatUpdatesOnConflict() { + String sql = """ + INSERT INTO users (id, name) + VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + active = $3, + version = users.version + 1, + updated_at = now() + RETURNING id"""; + + QueryModel model = analyzer.analyze( + new Query("UpsertUser", QueryType.ONE, sql), + parser.parse(sql), + writeSchema + ); + + assertEquals( + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + model.columns() + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR), + new QueryParameter(3, "active", ColumnType.BOOLEAN) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2, 3), model.bindingParameterIndexes()); + } + + /** + * A named upsert numbers its parameters by first occurrence, so a name + * repeated by the action binds the parameter the {@code VALUES} row + * introduced, and a cast placeholder inside an assigned expression is typed + * by its cast. + */ + @Test + void shouldAnalyzeNamedUpsertThatUpdatesOnConflict() { + String sql = """ + INSERT INTO users (id, name) + VALUES (:id, :name) + ON CONFLICT (id) DO UPDATE + SET name = :name, + bio = COALESCE(:bio::text, bio)"""; + + QueryModel model = analyzer.analyze( + new Query("UpsertUser", QueryType.EXEC, sql), + parser.parse(sql), + writeSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR), + new QueryParameter(3, "bio", ColumnType.TEXT) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2, 2, 3), model.bindingParameterIndexes()); + } + + /** + * A {@code DO NOTHING} action binds nothing, so the insert keeps exactly + * the parameters of its {@code VALUES} row, and a quoted target column + * resolves by its unquoted name. + */ + @Test + void shouldAnalyzeUpsertThatDoesNothingOnConflict() { + String sql = "INSERT INTO users (id, name) VALUES ($1, $2) ON CONFLICT (\"id\", name) DO NOTHING"; + + QueryModel model = analyzer.analyze( + new Query("UpsertUser", QueryType.EXEC, sql), + parser.parse(sql), + writeSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2), model.bindingParameterIndexes()); + + assertEquals( + "INSERT INTO users (id, name) VALUES (?, ?) ON CONFLICT (\"id\", name) DO NOTHING", + model.executableSql() + ); + } + + /** A returning upsert that does nothing still returns one complete row. */ + @Test + void shouldResolveRowTableForUpsertThatDoesNothingOnConflict() { + String sql = "INSERT INTO users (id, name) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING RETURNING *"; + + QueryModel model = analyzer.analyze( + new Query("UpsertUser", QueryType.OPTIONAL, sql), + parser.parse(sql), + writeSchema + ); + + assertEquals("users", model.rowTable()); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR) + ), + model.parameters() + ); + } + + /** + * A conflict target that is anything other than plain column names — an + * absent target, a named constraint, an expression, a collated column, or a + * column with an operator class — decides the conflict by something the + * analyzed model does not describe. + */ + @ParameterizedTest + @ValueSource( + strings = { + "ON CONFLICT DO NOTHING", + "ON CONFLICT ON CONSTRAINT users_pkey DO NOTHING", + "ON CONFLICT (lower(name)) DO NOTHING", + "ON CONFLICT (name COLLATE \"C\") DO NOTHING", + "ON CONFLICT (name text_pattern_ops) DO NOTHING" + } + ) + void shouldRejectAConflictTargetThatIsNotColumnNames(String conflictClause) { + assertConflictRejection( + conflictClause, + "ON CONFLICT requires a target of column names, such as ON CONFLICT (id)." + ); + } + + /** The remaining conflict forms outside the subset and their messages. */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "ON CONFLICT (id) WHERE active DO NOTHING|" + + "ON CONFLICT target predicates are not supported.", + "ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name WHERE users.active|" + + "ON CONFLICT DO UPDATE ... WHERE is not supported.", + "ON CONFLICT (id) DO UPDATE SET (name, active) = ('a', TRUE)|" + + "UPDATE assignments must set one column at a time." + } + ) + void shouldRejectExcludedConflictForm(String conflictClause, String message) { + assertConflictRejection(conflictClause, message); + } + + /** + * Asserts that one conflict clause is rejected with the same message on an + * {@code :exec} insert and on a returning insert. + */ + private void assertConflictRejection(String conflictClause, String message) { + String insert = "INSERT INTO users (id, name) VALUES ($1, $2) " + conflictClause; + + assertEquals(message, conflictRejection(QueryType.EXEC, insert).getMessage()); + + assertEquals( + message, + conflictRejection(QueryType.ONE, insert + " RETURNING id").getMessage() + ); + } + + private UnsupportedOperationException conflictRejection(QueryType type, String sql) { + Query query = new Query("UpsertUser", type, sql); + ParsedSql parsedSql = parser.parse(sql); + + return assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, writeSchema) + ); + } + + /** + * A placeholder inside a {@code DO UPDATE} value is analyzed only through + * its own cast, so an uncast one stays unaccounted. + */ + @Test + void shouldRejectAnUncastPlaceholderInsideAConflictUpdateValue() { + String sql = """ + INSERT INTO users (id, name) + VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET version = version + $3"""; + + Query query = new Query("UpsertUser", QueryType.EXEC, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, writeSchema) + ); + + assertEquals( + "SQL placeholders [1, 2, 3] are not the analyzed parameters [1, 2];" + + " a placeholder is in an unsupported location", + exception.getMessage() + ); + } + + /** + * A target column, an assigned column, and a column read from + * {@code EXCLUDED} must each exist in the inserted table. + */ + @ParameterizedTest + @ValueSource( + strings = { + "INSERT INTO users (id) VALUES ($1) ON CONFLICT (missing) DO NOTHING", + "INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO UPDATE SET missing = 'a'", + "INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.missing" + } + ) + void shouldRejectAnUnknownConflictColumn(String sql) { + Query query = new Query("UpsertUser", 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() + ); + } } 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 797dbf0..7ca7b8b 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -1214,6 +1214,160 @@ INSERT INTO users (bio, id, code, name, updated_at, serial_id) } } + /** + * Covers the accepted upsert shapes end to end: an {@code ON CONFLICT} + * target of column names with a {@code DO NOTHING} and a {@code DO UPDATE} + * action, with and without {@code RETURNING}, each executed over a new row + * and then over a conflicting one. + * + *

{@code DO NOTHING} writes no row on conflict, so its {@code :exec} + * form reports no affected row and its returning form finds none, while + * {@code DO UPDATE} writes the proposed {@code EXCLUDED} value, the bound + * placeholder, and the computed expression into the stored row. + */ + @Test + void shouldExecuteGeneratedUpsertsAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: InsertUserOrIgnore :exec + INSERT INTO users (id, code, name) + VALUES ($1, $2, $3) + ON CONFLICT (id) DO NOTHING; + + -- name: InsertUserOrFind :optional + INSERT INTO users (id, code, name) + VALUES ($1, $2, $3) + ON CONFLICT (id) DO NOTHING + RETURNING id, name; + + -- name: UpsertUser :exec + INSERT INTO users (id, code, name) + VALUES (:id, :code, :name) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + bio = :bio, + code = users.code + 1; + + -- name: UpsertUserReturning :one + INSERT INTO users (id, code, name) + VALUES ($1, $2, $3) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name, + bio = $4 + RETURNING id, code, name, bio; + + -- name: GetUser :one + SELECT id, code, name, bio + FROM users + WHERE id = $1; + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Method getUser = repository.getClass().getMethod("getUser", Long.class); + + Method insertOrIgnore = repository.getClass().getMethod( + "insertUserOrIgnore", + Long.class, + Integer.class, + String.class + ); + + assertEquals(1, insertOrIgnore.invoke(repository, 1L, 10, "New")); + assertEquals(0, insertOrIgnore.invoke(repository, 1L, 11, "Ignored")); + + Object kept = getUser.invoke(repository, 1L); + + assertEquals(10, component(kept, "code")); + assertEquals("New", component(kept, "name")); + + Method insertOrFind = repository.getClass().getMethod( + "insertUserOrFind", + Long.class, + Integer.class, + String.class + ); + + Optional inserted = assertInstanceOf( + Optional.class, + insertOrFind.invoke(repository, 2L, 20, "Returned") + ); + + assertEquals(2L, component(inserted.orElseThrow(), "id")); + assertEquals("Returned", component(inserted.orElseThrow(), "name")); + + assertTrue( + assertInstanceOf( + Optional.class, + insertOrFind.invoke(repository, 2L, 21, "Ignored") + ).isEmpty() + ); + + Method upsertUser = repository.getClass().getMethod( + "upsertUser", + Long.class, + Integer.class, + String.class, + String.class + ); + + assertEquals(1, upsertUser.invoke(repository, 3L, 30, "First", "first note")); + + Object created = getUser.invoke(repository, 3L); + + assertEquals(30, component(created, "code")); + assertEquals("First", component(created, "name")); + assertNull(component(created, "bio")); + + assertEquals(1, upsertUser.invoke(repository, 3L, 99, "Second", "second note")); + + Object updated = getUser.invoke(repository, 3L); + + assertEquals(31, component(updated, "code")); + assertEquals("Second", component(updated, "name")); + assertEquals("second note", component(updated, "bio")); + + Method upsertReturning = repository.getClass().getMethod( + "upsertUserReturning", + Long.class, + Integer.class, + String.class, + String.class + ); + + Object returnedInsert = upsertReturning.invoke( + repository, + 4L, + 40, + "Fourth", + "fourth note" + ); + + assertEquals( + List.of("id", "code", "name", "bio"), + recordComponentNames(returnedInsert) + ); + + assertEquals(4L, component(returnedInsert, "id")); + assertEquals(40, component(returnedInsert, "code")); + assertEquals("Fourth", component(returnedInsert, "name")); + assertNull(component(returnedInsert, "bio")); + + Object returnedUpdate = upsertReturning.invoke( + repository, + 4L, + 99, + "Fourth Again", + "fifth note" + ); + + assertEquals(4L, component(returnedUpdate, "id")); + assertEquals(40, component(returnedUpdate, "code")); + assertEquals("Fourth Again", component(returnedUpdate, "name")); + assertEquals("fifth note", component(returnedUpdate, "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 56bf061dc38de9ba56e68d242fab559627c6a372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sat, 3 Oct 2026 19:12:42 +0300 Subject: [PATCH 2/2] docs: document upserts with ON CONFLICT --- docs/queries.md | 90 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/docs/queries.md b/docs/queries.md index e5bd771..4492f7b 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -295,7 +295,7 @@ A write targets exactly one table of its entry's schema. | Statement | Accepted shape | | --- | --- | -| `INSERT` | an explicit column list and a single `VALUES` row, each value a placeholder or an expression that binds none | +| `INSERT` | an explicit column list and a single `VALUES` row, each value a placeholder or an expression that binds none, with an optional `ON CONFLICT` clause | | `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 | @@ -356,6 +356,53 @@ the whole value, as in `SET bio = :bio::text`, is typed by its cast as well and named after its column. A value that contains an uncast placeholder, such as `COALESCE($2, bio)`, stays rejected. +### Upsert with `ON CONFLICT` + +An `INSERT` may end with one conflict clause whose target is a parenthesized +list of plain column names of the inserted table, followed by one action: + +| Action | Accepted shape | +| --- | --- | +| `DO NOTHING` | no assignment, so the statement binds only its `VALUES` row | +| `DO UPDATE SET` | assignments analyzed exactly as `UPDATE` assignments are, where a value may also be `EXCLUDED.column` | + +```sql +-- name: UpsertAuthor :one +INSERT INTO authors (id, name, bio) +VALUES (:id, :name, :bio) +ON CONFLICT (id) DO UPDATE +SET name = EXCLUDED.name, + bio = :bio, + version = authors.version + 1, + updated_at = now() +RETURNING *; +``` + +`UpsertAuthor` generates `upsertAuthor(Long id, String name, String bio)` and +returns the `AuthorsRow` it inserted or updated. + +A `DO UPDATE` placeholder is written after the `VALUES` placeholders, so it +binds after them: on a two-value insert, +`ON CONFLICT (id) DO UPDATE SET active = $3` binds `$1`, `$2`, and `$3` in that +order, and the repeated `:bio` above is bound at both of its positions. A +`DO UPDATE` placeholder is typed by its assigned column, or by its own cast +wherever a cast appears inside the assigned value. + +`EXCLUDED.column` reads the proposed row, and that column must exist in the +inserted table. An `EXCLUDED` reference inside a larger expression is not +resolved, as no other computed write value is, so PostgreSQL checks it. sqlcj +does not match the conflict target against a unique index; PostgreSQL does. + +A `DO NOTHING` action writes no row when a conflict occurs, so +`DO NOTHING ... RETURNING` returns no row on conflict and suits `:optional` or +`:many` rather than `:one`. A `DO UPDATE ... RETURNING` always returns the row +it inserted or updated. + +Every other conflict form is rejected on every insert, whether `:exec` or +returning: a missing target, `ON CONFLICT ON CONSTRAINT`, a target element that +is an expression or carries a collation or an operator class, a conflict-target +predicate, and `DO UPDATE ... WHERE`. + ### `RETURNING` A supported `INSERT`, `UPDATE`, or `DELETE` declared `:one`, `:optional`, or @@ -372,9 +419,10 @@ the target table's shared `Row` record, while a `RETURNING` column list keeps the query's own `Result` record. An aliased or computed `RETURNING` item, a qualified `table.*`, an unknown -column, and `RETURNING` on `:exec` are rejected. `ON CONFLICT`, multi-row -`VALUES`, `INSERT ... SELECT`, `UPDATE ... FROM`, `DELETE ... USING`, and common -table expressions are rejected in a returning write. +column, and `RETURNING` on `:exec` are rejected. Multi-row `VALUES`, +`INSERT ... SELECT`, `UPDATE ... FROM`, `DELETE ... USING`, and common table +expressions are rejected in a returning write, while an accepted `ON CONFLICT` +clause is analyzed the same way with and without `RETURNING`. ## Parameters @@ -721,9 +769,16 @@ name, and its header line. 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. +- A conflict clause outside the accepted upsert shape, on an `:exec` and on a + returning insert alike: `ON CONFLICT` without a target, `ON CONFLICT ON + CONSTRAINT`, a target element that is an expression or carries a collation or + an operator class, a conflict-target predicate such as + `ON CONFLICT (id) WHERE active`, and `DO UPDATE ... WHERE`. A `DO UPDATE` + assignment that sets a column list, and an unknown target, assigned, or + `EXCLUDED` column, are rejected as the write forms above are. - 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. + `RETURNING` on `:exec`; and `UPDATE ... FROM`, `DELETE ... USING`, or a common + table expression in a returning write. - Any annotation other than `:one`, `:optional`, `:many`, and `:exec`. ### Not analyzed @@ -739,8 +794,9 @@ them: 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, -- `ON CONFLICT`, `UPDATE ... FROM`, and `DELETE ... USING` on a non-returning - `:exec` write. +- `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 @@ -916,6 +972,24 @@ Writes and `RETURNING`: `QueryAnalyzerTest.shouldRejectExcludedWriteValueForm` cover the values that bind no placeholder, their numbering and binding order, and the nearby rejections. +- `QueryAnalyzerTest.shouldAnalyzeUpsertThatUpdatesOnConflict`, + `QueryAnalyzerTest.shouldAnalyzeReturningUpsertThatUpdatesOnConflict`, + `QueryAnalyzerTest.shouldAnalyzeNamedUpsertThatUpdatesOnConflict`, + `QueryAnalyzerTest.shouldAnalyzeUpsertThatDoesNothingOnConflict`, + `QueryAnalyzerTest.shouldResolveRowTableForUpsertThatDoesNothingOnConflict`, + `QueryAnalyzerTest.shouldRejectAConflictTargetThatIsNotColumnNames`, + `QueryAnalyzerTest.shouldRejectExcludedConflictForm`, + `QueryAnalyzerTest.shouldRejectAnUncastPlaceholderInsideAConflictUpdateValue`, + and `QueryAnalyzerTest.shouldRejectAnUnknownConflictColumn` cover the accepted + conflict target, both actions, the `EXCLUDED`, placeholder, cast, and + computed assignments, the binding order after the `VALUES` placeholders, the + returning row type, and the conflict forms, unaccounted placeholder, and + unknown target, assigned, and `EXCLUDED` columns that are rejected on an + `:exec` and on a returning insert alike, and + `PostgresIntegrationTest.shouldExecuteGeneratedUpsertsAgainstPostgres` + executes a `DO NOTHING` and a `DO UPDATE` action, each with and without + `RETURNING`, over a new row and then a conflicting one against + PostgreSQL 16. - `QueryAnalyzerTest.shouldResolveRowTableForReturningAllColumns` covers the returning writes that produce a complete table row. - `PostgresIntegrationTest.shouldExecuteGeneratedWriteAgainstPostgres`,