From bc5d326fff6931453c962b107d1a4b695b156fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Sun, 11 Oct 2026 16:27:32 +0300 Subject: [PATCH] feat(analysis): accept targetless, named, and predicated ON CONFLICT An INSERT may now use ON CONFLICT DO NOTHING without a target, ON CONFLICT ON CONSTRAINT name with either action, a predicate on a column-name conflict target, and DO UPDATE ... WHERE, on :exec and returning inserts alike. The constraint name is resolved by PostgreSQL. Placeholders in a target predicate are typed by the WHERE rules over the inserted table under its exposed name; those in DO UPDATE ... WHERE over that table and EXCLUDED, so an unqualified column of both is ambiguous. All conflict placeholders bind in textual order. DO UPDATE without a target is rejected naming ON CONFLICT (id) and ON CONSTRAINT as the fix. --- docs/queries.md | 98 +++++- .../dev/sqlcj/analysis/QueryAnalyzer.java | 86 +++-- .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 303 +++++++++++++++++- .../compiler/PostgresIntegrationTest.java | 161 ++++++++++ 4 files changed, 587 insertions(+), 61 deletions(-) diff --git a/docs/queries.md b/docs/queries.md index 38608b2..8b42104 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -429,13 +429,19 @@ named after its column. A value that contains an uncast placeholder, such as ### 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: +An `INSERT` may end with one conflict clause that names which conflict it +handles, followed by one action: + +| Target | Accepted shape | +| --- | --- | +| none | accepted with `DO NOTHING`, which needs no target; `DO UPDATE` requires one | +| a column list | a parenthesized list of plain column names of the inserted table, with an optional predicate, as in `ON CONFLICT (code) WHERE active` | +| `ON CONSTRAINT name` | accepted with either action; the name is resolved by PostgreSQL | | 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` | +| `DO UPDATE SET` | assignments analyzed exactly as `UPDATE` assignments are, where a value may also be `EXCLUDED.column`, with an optional `WHERE` predicate | ```sql -- name: UpsertAuthor :one @@ -462,17 +468,57 @@ 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. +matches neither the conflict target against a unique index nor a constraint +name against the schema; PostgreSQL resolves both. + +Either predicate uses the read predicate forms, and a placeholder in one is +typed exactly as the same comparison types it in a `WHERE` clause: + +```sql +-- name: PublishAuthorIfNewer :optional +INSERT INTO authors (id, name, version) +VALUES ($1, $2, $3) +ON CONFLICT (name) WHERE active DO UPDATE +SET version = EXCLUDED.version +WHERE authors.version < EXCLUDED.version + AND authors.name <> $4 +RETURNING id, name, version; +``` + +The two predicates read different sources: + +- a conflict-target predicate reads the inserted table alone, under the name + that table exposes, which is its alias when the `INSERT` writes one, as in + `INSERT INTO authors AS a`; +- a `DO UPDATE ... WHERE` predicate reads both the stored row, under the same + exposed name, and the proposed row as `EXCLUDED`. Both carry the same + columns, so an unqualified column compared with a placeholder there is + ambiguous, as PostgreSQL also reports it; write `authors.version` or + `EXCLUDED.version`. + +A placeholder in a conflict-target predicate is typed and bound like any other, +but PostgreSQL chooses the arbiter index when it plans the statement rather than +when it runs it, so a parameterized target predicate stops matching a partial +unique index once the statement is executed under a generic plan and the insert +then fails. Write a target predicate with literals that imply the index's own +predicate, as in `WHERE active` or `WHERE active = TRUE`, and keep the +placeholders in the `DO UPDATE ... WHERE` predicate. + +Every conflict placeholder binds in the textual order the query writes it, so +`ON CONFLICT (name) WHERE active DO UPDATE SET bio = $4 WHERE authors.version < $3` +on a two-value insert binds `$1`, `$2`, `$4`, and `$3` in that order, while each +generated method parameter keeps its own index. 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. +`:many` rather than `:one`. A `DO UPDATE ... WHERE` whose predicate does not +hold writes no row either, so it returns none as well. A `DO UPDATE` without a +predicate 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 `DO UPDATE` without a target, which is rejected naming the two +targets it accepts, and a target element that is an expression or carries a +collation or an operator class. ### `RETURNING` @@ -863,12 +909,14 @@ name, and its header line. 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` + returning insert alike: a `DO UPDATE` without a conflict target, which is + rejected naming the two targets it accepts, and a target element that is an + expression or carries a collation or an operator class. 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. + `EXCLUDED` column, are rejected as the write forms above are, and so is an + unqualified conflict-predicate column that both the inserted table and + `EXCLUDED` carry, or a predicate column qualified with a name its own + predicate does not read, such as `EXCLUDED` in a conflict-target predicate. - An aliased, computed, qualified-wildcard, or unknown `RETURNING` item; `RETURNING` on `:exec`; and `UPDATE ... FROM`, `DELETE ... USING`, or a common table expression in a returning write. @@ -889,8 +937,10 @@ them: 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`. +- the unique index an accepted `ON CONFLICT` target matches, the constraint an + `ON CONSTRAINT` target names, the column references of an `EXCLUDED` value + that is not exactly `EXCLUDED.column`, and a conflict-predicate column that + no placeholder is compared with, as in a `WHERE` clause. A clause that is not analyzed still binds the placeholders a cast types inside it, in textual order, and nothing else of it is checked. A cast placeholder in a @@ -1159,6 +1209,22 @@ Writes and `RETURNING`: 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.shouldAnalyzeUpsertWithoutAConflictTarget`, + `QueryAnalyzerTest.shouldAnalyzeUpsertOnANamedConstraint`, + `QueryAnalyzerTest.shouldAnalyzeAPlaceholderInAConflictTargetPredicate`, + `QueryAnalyzerTest.shouldAnalyzeANamedPlaceholderInAConflictTargetPredicate`, + `QueryAnalyzerTest.shouldAnalyzePlaceholdersInAConflictUpdatePredicate`, + `QueryAnalyzerTest.shouldAnalyzeAConflictUpdatePredicateThroughTheInsertedTableAlias`, + `QueryAnalyzerTest.shouldBindConflictPredicatesInTextualOrder`, and + `QueryAnalyzerTest.shouldRejectAConflictPredicateColumnOutsideItsScope` cover + the conflict forms that name no column target: a targetless `DO NOTHING`, a + named constraint with either action, both predicates and the scope each one + reads, their placeholder types and names, their textual binding order, and + the ambiguous and out-of-scope predicate columns, and + `PostgresIntegrationTest.shouldExecuteGeneratedConflictClausesAgainstPostgres` + executes each of those forms against PostgreSQL 16, including a target + predicate that selects a partial unique index and a `DO UPDATE ... WHERE` + that updates once and then skips the row. - `QueryAnalyzerTest.shouldResolveRowTableForReturningAllColumns` covers the returning writes that produce a complete table row. - `PostgresIntegrationTest.shouldExecuteGeneratedWriteAgainstPostgres`, 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 a5e58d0..6e2171e 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -374,7 +374,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); + resolveConflictParameters(insert, source, placeholders); return toQueryModel( query, @@ -711,13 +711,19 @@ 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. + * after the {@code VALUES} placeholders and therefore bind after them. A + * {@code DO UPDATE ... WHERE} predicate reads both the stored row and the + * proposed one, so its placeholders follow the {@code WHERE} rules over the + * inserted table and {@code EXCLUDED} as two sources of the same columns, + * which makes an unqualified column of either one ambiguous, as PostgreSQL + * also reports it. + * + *

{@code DO NOTHING} decides the conflict by itself, so it needs no + * target, while {@code DO UPDATE} must name the rows it updates. */ private void resolveConflictParameters( Insert insert, - dev.sqlcj.schema.Table table, + Source source, Placeholders placeholders ) { InsertConflictTarget target = insert.getConflictTarget(); @@ -727,41 +733,59 @@ private void resolveConflictParameters( return; } - resolveConflictTarget(target, table); + boolean doUpdate = action != null + && action.getConflictActionType() == ConflictActionType.DO_UPDATE; - if (action == null || action.getConflictActionType() != ConflictActionType.DO_UPDATE) { - return; - } - - if (action.getWhereExpression() != null) { + if (target != null) { + resolveConflictTarget(target, source, placeholders); + } else if (doUpdate) { throw new UnsupportedOperationException( - "ON CONFLICT DO UPDATE ... WHERE is not supported." + "ON CONFLICT DO UPDATE requires a conflict target," + + " such as ON CONFLICT (id) or ON CONFLICT ON CONSTRAINT name." ); } + if (!doUpdate) { + return; + } + List updateSets = action.getUpdateSets(); - resolveUpdateSetParameters(updateSets, table, placeholders); - resolveExcludedColumns(updateSets, table); + resolveUpdateSetParameters(updateSets, source.table(), placeholders); + resolveExcludedColumns(updateSets, source.table()); + + if (action.getWhereExpression() != null) { + resolveParameters( + action.getWhereExpression(), + List.of(source, new Source(EXCLUDED_QUALIFIER, source.table(), false)), + placeholders + ); + } } /** - * 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. + * Resolves the conflict target against the inserted table. Each element of + * a target of plain column names is a column of that table, and the + * target's predicate reads that table alone, under the name it exposes. A named + * constraint, like the unique index a column-name target matches, is + * resolved by PostgreSQL. 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 + Source source, + Placeholders placeholders ) { - boolean columnNames = target != null - && target.getConstraintName() == null - && target.getIndexElements().stream().noneMatch( - element -> element.isExpression() - || element.getCollation() != null - || element.getOperatorClass() != null - ); + if (target.getConstraintName() != null) { + return; + } + + boolean columnNames = target.getIndexElements().stream().noneMatch( + element -> element.isExpression() + || element.getCollation() != null + || element.getOperatorClass() != null + ); if (!columnNames) { throw new UnsupportedOperationException( @@ -769,14 +793,12 @@ private void resolveConflictTarget( ); } - if (target.getWhereExpression() != null) { - throw new UnsupportedOperationException( - "ON CONFLICT target predicates are not supported." - ); + for (Index.ColumnParams element : target.getIndexElements()) { + findColumn(source.table(), MultiPartName.unquote(element.getColumnName())); } - for (Index.ColumnParams element : target.getIndexElements()) { - findColumn(table, MultiPartName.unquote(element.getColumnName())); + if (target.getWhereExpression() != null) { + resolveParameters(target.getWhereExpression(), List.of(source), 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 1b1780a..dfc99bb 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -4321,8 +4321,6 @@ void shouldRejectUnknownReturningColumn() { @CsvSource( delimiter = '|', value = { - "INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING id" - + "|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" @@ -5229,16 +5227,277 @@ void shouldResolveRowTableForUpsertThatDoesNothingOnConflict() { } /** - * 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. + * A {@code DO NOTHING} action decides the conflict by itself, so an insert + * may omit the conflict target and still bind exactly the parameters of its + * {@code VALUES} row, on an {@code :exec} and on a returning insert alike. + */ + @Test + void shouldAnalyzeUpsertWithoutAConflictTarget() { + List parameters = List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR) + ); + + QueryModel model = analyzeConflict(QueryType.EXEC, "ON CONFLICT DO NOTHING"); + + assertEquals(parameters, model.parameters()); + assertEquals(List.of(1, 2), model.bindingParameterIndexes()); + + assertEquals( + "INSERT INTO users (id, name) VALUES (?, ?) ON CONFLICT DO NOTHING", + model.executableSql() + ); + + QueryModel returning = analyzeConflict( + QueryType.OPTIONAL, + "ON CONFLICT DO NOTHING RETURNING id" + ); + + assertEquals( + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + returning.columns() + ); + + assertEquals(parameters, returning.parameters()); + assertEquals(List.of(1, 2), returning.bindingParameterIndexes()); + } + + /** + * A named constraint names the conflict PostgreSQL resolves, as the unique + * index a column-name target matches is also resolved there, so sqlcj + * accepts it with either action and analyzes only what the action writes. + */ + @Test + void shouldAnalyzeUpsertOnANamedConstraint() { + List parameters = List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR) + ); + + for (QueryType type : List.of(QueryType.EXEC, QueryType.OPTIONAL)) { + String returning = type == QueryType.EXEC ? "" : " RETURNING id"; + + QueryModel doNothing = analyzeConflict( + type, + "ON CONFLICT ON CONSTRAINT users_pkey DO NOTHING" + returning + ); + + assertEquals(parameters, doNothing.parameters()); + assertEquals(List.of(1, 2), doNothing.bindingParameterIndexes()); + + QueryModel doUpdate = analyzeConflict( + type, + "ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE" + + " SET name = EXCLUDED.name, active = $3" + returning + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR), + new QueryParameter(3, "active", ColumnType.BOOLEAN) + ), + doUpdate.parameters() + ); + + assertEquals(List.of(1, 2, 3), doUpdate.bindingParameterIndexes()); + } + } + + /** + * A conflict-target predicate selects the partial unique index PostgreSQL + * matches, and reads the inserted table alone, so a placeholder compared + * with one of its columns is typed by the {@code WHERE} rules. A predicate + * that compares nothing with a placeholder binds nothing of its own. + */ + @Test + void shouldAnalyzeAPlaceholderInAConflictTargetPredicate() { + QueryModel model = analyzeConflict( + QueryType.EXEC, + "ON CONFLICT (name) WHERE active = $3 DO NOTHING" + ); + + 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 (name) WHERE active = ? DO NOTHING", + model.executableSql() + ); + + QueryModel bare = analyzeConflict( + QueryType.EXEC, + "ON CONFLICT (name) WHERE active DO NOTHING" + ); + + assertEquals(List.of(1, 2), bare.bindingParameterIndexes()); + } + + /** A named target-predicate placeholder keeps the name it is written with. */ + @Test + void shouldAnalyzeANamedPlaceholderInAConflictTargetPredicate() { + String sql = "INSERT INTO users (id, name) VALUES (:id, :name)" + + " ON CONFLICT (name) WHERE active = :flag 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), + new QueryParameter(3, "flag", ColumnType.BOOLEAN) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2, 3), model.bindingParameterIndexes()); + } + + /** + * A {@code DO UPDATE ... WHERE} predicate reads both the stored row and the + * proposed {@code EXCLUDED} row, so a qualified column of either one types + * the placeholder it is compared with. + */ + @Test + void shouldAnalyzePlaceholdersInAConflictUpdatePredicate() { + QueryModel model = analyzeConflict( + QueryType.EXEC, + "ON CONFLICT (id) DO UPDATE SET name = $3" + + " WHERE users.version < $4 AND EXCLUDED.nickname <> $5" + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR), + new QueryParameter(3, "name", ColumnType.VARCHAR), + new QueryParameter(4, "version", ColumnType.INTEGER), + new QueryParameter(5, "nickname", ColumnType.VARCHAR) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2, 3, 4, 5), model.bindingParameterIndexes()); + } + + /** + * The inserted table exposes its alias to a conflict predicate, as a read + * source exposes its own. + */ + @Test + void shouldAnalyzeAConflictUpdatePredicateThroughTheInsertedTableAlias() { + String sql = """ + INSERT INTO users AS u (id, name) + VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name + WHERE u.version < $3"""; + + 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, "version", ColumnType.INTEGER) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2, 3), model.bindingParameterIndexes()); + } + + /** + * Both conflict predicates bind in the textual order the query writes them, + * so a target predicate binds before the {@code DO UPDATE} assignments it + * is written before. + */ + @Test + void shouldBindConflictPredicatesInTextualOrder() { + QueryModel model = analyzeConflict( + QueryType.EXEC, + "ON CONFLICT (name) WHERE active = $5 DO UPDATE" + + " SET bio = $3 WHERE users.version < $4" + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR), + new QueryParameter(3, "bio", ColumnType.TEXT), + new QueryParameter(4, "version", ColumnType.INTEGER), + new QueryParameter(5, "active", ColumnType.BOOLEAN) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2, 5, 3, 4), model.bindingParameterIndexes()); + } + + /** + * Each conflict predicate resolves a placeholder's column against its own + * scope: the inserted table and {@code EXCLUDED} both carry the columns a + * {@code DO UPDATE ... WHERE} predicate reads, so an unqualified one is + * ambiguous, while a target predicate reads the inserted table alone. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name WHERE version = $3|" + + "Ambiguous column reference 'version' in sources: users, EXCLUDED", + "ON CONFLICT (name) WHERE EXCLUDED.active = $3 DO NOTHING|" + + "Unknown source qualifier in query: EXCLUDED" + } + ) + void shouldRejectAConflictPredicateColumnOutsideItsScope( + String conflictClause, + String message + ) { + String insert = "INSERT INTO users (id, name) VALUES ($1, $2) " + conflictClause; + + assertEquals( + message, + conflictRejection(IllegalArgumentException.class, QueryType.EXEC, insert) + .getMessage() + ); + + assertEquals( + message, + conflictRejection( + IllegalArgumentException.class, + QueryType.ONE, + insert + " RETURNING id" + ).getMessage() + ); + } + + /** + * A conflict target element that is an expression or carries a collation or + * 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" @@ -5257,10 +5516,9 @@ void shouldRejectAConflictTargetThatIsNotColumnNames(String conflictClause) { 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 DO UPDATE SET name = EXCLUDED.name|" + + "ON CONFLICT DO UPDATE requires a conflict target, such as" + + " ON CONFLICT (id) or ON CONFLICT ON CONSTRAINT name.", "ON CONFLICT (id) DO UPDATE SET (name, active) = ('a', TRUE)|" + "UPDATE assignments must set one column at a time." } @@ -5284,12 +5542,31 @@ private void assertConflictRejection(String conflictClause, String message) { ); } + /** Analyzes one conflict clause on an insert of the write schema. */ + private QueryModel analyzeConflict(QueryType type, String conflictClause) { + String sql = "INSERT INTO users (id, name) VALUES ($1, $2) " + conflictClause; + + return analyzer.analyze( + new Query("UpsertUser", type, sql), + parser.parse(sql), + writeSchema + ); + } + private UnsupportedOperationException conflictRejection(QueryType type, String sql) { + return conflictRejection(UnsupportedOperationException.class, type, sql); + } + + private T conflictRejection( + Class exceptionType, + QueryType type, + String sql + ) { Query query = new Query("UpsertUser", type, sql); ParsedSql parsedSql = parser.parse(sql); return assertThrows( - UnsupportedOperationException.class, + exceptionType, () -> analyzer.analyze(query, parsedSql, writeSchema) ); } 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 485e53d..ec8bffa 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -1701,6 +1701,167 @@ ON CONFLICT (id) DO UPDATE } } + /** + * Covers the conflict clauses that name no column target end to end: a + * {@code DO NOTHING} without a target, a named constraint with either + * action, a conflict-target predicate selecting a partial unique index, and + * a {@code DO UPDATE ... WHERE} predicate that compares a stored column + * with the proposed {@code EXCLUDED} one and with a named placeholder. + * + *

PostgreSQL resolves the constraint name and matches the partial index + * from the target predicate, and it applies the {@code DO UPDATE} + * predicate itself, so the returning upsert writes and returns a row only + * while that predicate holds. + */ + @Test + void shouldExecuteGeneratedConflictClausesAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: InsertUserOrIgnore :exec + INSERT INTO users (id, code, name) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING; + + -- name: FindOrInsertUser :optional + INSERT INTO users (id, code, name) + VALUES ($1, $2, $3) + ON CONFLICT ON CONSTRAINT users_pkey DO NOTHING + RETURNING id, name; + + -- name: RenameUserOnConstraint :exec + INSERT INTO users (id, code, name) + VALUES ($1, $2, $3) + ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE + SET name = EXCLUDED.name; + + -- name: InsertActiveUserOrIgnore :exec + INSERT INTO users (id, code, name, active) + VALUES ($1, $2, $3, TRUE) + ON CONFLICT (code) WHERE active DO NOTHING; + + -- name: UpsertUserIfNewer :optional + INSERT INTO users (id, code, name) + VALUES (:id, :code, :name) + ON CONFLICT (id) DO UPDATE + SET name = EXCLUDED.name + WHERE users.code < EXCLUDED.code + AND users.name <> :blocked + RETURNING id, code, name; + + -- name: GetUser :one + SELECT id, code, name + FROM users + WHERE id = $1; + """); + + execute("CREATE UNIQUE INDEX users_active_code_key ON users (code) WHERE active"); + + 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 findOrInsert = repository.getClass().getMethod( + "findOrInsertUser", + Long.class, + Integer.class, + String.class + ); + + Optional inserted = assertInstanceOf( + Optional.class, + findOrInsert.invoke(repository, 2L, 20, "Returned") + ); + + assertEquals(2L, component(inserted.orElseThrow(), "id")); + assertEquals("Returned", component(inserted.orElseThrow(), "name")); + + assertTrue( + assertInstanceOf( + Optional.class, + findOrInsert.invoke(repository, 2L, 21, "Ignored") + ).isEmpty() + ); + + Method renameOnConstraint = repository.getClass().getMethod( + "renameUserOnConstraint", + Long.class, + Integer.class, + String.class + ); + + assertEquals(1, renameOnConstraint.invoke(repository, 2L, 22, "Renamed")); + + Object renamed = getUser.invoke(repository, 2L); + + assertEquals(20, component(renamed, "code")); + assertEquals("Renamed", component(renamed, "name")); + + Method insertActiveOrIgnore = repository.getClass().getMethod( + "insertActiveUserOrIgnore", + Long.class, + Integer.class, + String.class + ); + + assertEquals(1, insertActiveOrIgnore.invoke(repository, 3L, 30, "Active")); + assertEquals(0, insertActiveOrIgnore.invoke(repository, 4L, 30, "Skipped")); + + Method upsertIfNewer = repository.getClass().getMethod( + "upsertUserIfNewer", + Long.class, + Integer.class, + String.class, + String.class + ); + + Object created = assertInstanceOf( + Optional.class, + upsertIfNewer.invoke(repository, 5L, 50, "First", "Blocked") + ).orElseThrow(); + + assertEquals(List.of("id", "code", "name"), recordComponentNames(created)); + + assertEquals(5L, component(created, "id")); + assertEquals(50, component(created, "code")); + assertEquals("First", component(created, "name")); + + Object updated = assertInstanceOf( + Optional.class, + upsertIfNewer.invoke(repository, 5L, 60, "Second", "Blocked") + ).orElseThrow(); + + assertEquals(50, component(updated, "code")); + assertEquals("Second", component(updated, "name")); + + assertTrue( + assertInstanceOf( + Optional.class, + upsertIfNewer.invoke(repository, 5L, 70, "Third", "Second") + ).isEmpty() + ); + + Object unchanged = getUser.invoke(repository, 5L); + + assertEquals(50, component(unchanged, "code")); + assertEquals("Second", component(unchanged, "name")); + } + } + /** * Covers null parameter binding and null result reading for every nullable * column type of the schema snapshot: the row is written through the