Skip to content

Commit 1b5e9d7

Browse files
authored
Merge pull request #73 from sqlcj-dev/feature/targetless-named-and-predicated-conflict-clauses
feat(analysis): accept targetless, named, and predicated ON CONFLICT
2 parents 2366ff5 + bc5d326 commit 1b5e9d7

4 files changed

Lines changed: 587 additions & 61 deletions

File tree

‎docs/queries.md‎

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -429,13 +429,19 @@ named after its column. A value that contains an uncast placeholder, such as
429429

430430
### Upsert with `ON CONFLICT`
431431

432-
An `INSERT` may end with one conflict clause whose target is a parenthesized
433-
list of plain column names of the inserted table, followed by one action:
432+
An `INSERT` may end with one conflict clause that names which conflict it
433+
handles, followed by one action:
434+
435+
| Target | Accepted shape |
436+
| --- | --- |
437+
| none | accepted with `DO NOTHING`, which needs no target; `DO UPDATE` requires one |
438+
| 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` |
439+
| `ON CONSTRAINT name` | accepted with either action; the name is resolved by PostgreSQL |
434440

435441
| Action | Accepted shape |
436442
| --- | --- |
437443
| `DO NOTHING` | no assignment, so the statement binds only its `VALUES` row |
438-
| `DO UPDATE SET` | assignments analyzed exactly as `UPDATE` assignments are, where a value may also be `EXCLUDED.column` |
444+
| `DO UPDATE SET` | assignments analyzed exactly as `UPDATE` assignments are, where a value may also be `EXCLUDED.column`, with an optional `WHERE` predicate |
439445

440446
```sql
441447
-- name: UpsertAuthor :one
@@ -462,17 +468,57 @@ wherever a cast appears inside the assigned value.
462468
`EXCLUDED.column` reads the proposed row, and that column must exist in the
463469
inserted table. An `EXCLUDED` reference inside a larger expression is not
464470
resolved, as no other computed write value is, so PostgreSQL checks it. sqlcj
465-
does not match the conflict target against a unique index; PostgreSQL does.
471+
matches neither the conflict target against a unique index nor a constraint
472+
name against the schema; PostgreSQL resolves both.
473+
474+
Either predicate uses the read predicate forms, and a placeholder in one is
475+
typed exactly as the same comparison types it in a `WHERE` clause:
476+
477+
```sql
478+
-- name: PublishAuthorIfNewer :optional
479+
INSERT INTO authors (id, name, version)
480+
VALUES ($1, $2, $3)
481+
ON CONFLICT (name) WHERE active DO UPDATE
482+
SET version = EXCLUDED.version
483+
WHERE authors.version < EXCLUDED.version
484+
AND authors.name <> $4
485+
RETURNING id, name, version;
486+
```
487+
488+
The two predicates read different sources:
489+
490+
- a conflict-target predicate reads the inserted table alone, under the name
491+
that table exposes, which is its alias when the `INSERT` writes one, as in
492+
`INSERT INTO authors AS a`;
493+
- a `DO UPDATE ... WHERE` predicate reads both the stored row, under the same
494+
exposed name, and the proposed row as `EXCLUDED`. Both carry the same
495+
columns, so an unqualified column compared with a placeholder there is
496+
ambiguous, as PostgreSQL also reports it; write `authors.version` or
497+
`EXCLUDED.version`.
498+
499+
A placeholder in a conflict-target predicate is typed and bound like any other,
500+
but PostgreSQL chooses the arbiter index when it plans the statement rather than
501+
when it runs it, so a parameterized target predicate stops matching a partial
502+
unique index once the statement is executed under a generic plan and the insert
503+
then fails. Write a target predicate with literals that imply the index's own
504+
predicate, as in `WHERE active` or `WHERE active = TRUE`, and keep the
505+
placeholders in the `DO UPDATE ... WHERE` predicate.
506+
507+
Every conflict placeholder binds in the textual order the query writes it, so
508+
`ON CONFLICT (name) WHERE active DO UPDATE SET bio = $4 WHERE authors.version < $3`
509+
on a two-value insert binds `$1`, `$2`, `$4`, and `$3` in that order, while each
510+
generated method parameter keeps its own index.
466511

467512
A `DO NOTHING` action writes no row when a conflict occurs, so
468513
`DO NOTHING ... RETURNING` returns no row on conflict and suits `:optional` or
469-
`:many` rather than `:one`. A `DO UPDATE ... RETURNING` always returns the row
470-
it inserted or updated.
514+
`:many` rather than `:one`. A `DO UPDATE ... WHERE` whose predicate does not
515+
hold writes no row either, so it returns none as well. A `DO UPDATE` without a
516+
predicate always returns the row it inserted or updated.
471517

472518
Every other conflict form is rejected on every insert, whether `:exec` or
473-
returning: a missing target, `ON CONFLICT ON CONSTRAINT`, a target element that
474-
is an expression or carries a collation or an operator class, a conflict-target
475-
predicate, and `DO UPDATE ... WHERE`.
519+
returning: a `DO UPDATE` without a target, which is rejected naming the two
520+
targets it accepts, and a target element that is an expression or carries a
521+
collation or an operator class.
476522

477523
### `RETURNING`
478524

@@ -863,12 +909,14 @@ name, and its header line.
863909
as `SET (name, active) = ('a', TRUE)`; and a written column that the table does
864910
not declare.
865911
- A conflict clause outside the accepted upsert shape, on an `:exec` and on a
866-
returning insert alike: `ON CONFLICT` without a target, `ON CONFLICT ON
867-
CONSTRAINT`, a target element that is an expression or carries a collation or
868-
an operator class, a conflict-target predicate such as
869-
`ON CONFLICT (id) WHERE active`, and `DO UPDATE ... WHERE`. A `DO UPDATE`
912+
returning insert alike: a `DO UPDATE` without a conflict target, which is
913+
rejected naming the two targets it accepts, and a target element that is an
914+
expression or carries a collation or an operator class. A `DO UPDATE`
870915
assignment that sets a column list, and an unknown target, assigned, or
871-
`EXCLUDED` column, are rejected as the write forms above are.
916+
`EXCLUDED` column, are rejected as the write forms above are, and so is an
917+
unqualified conflict-predicate column that both the inserted table and
918+
`EXCLUDED` carry, or a predicate column qualified with a name its own
919+
predicate does not read, such as `EXCLUDED` in a conflict-target predicate.
872920
- An aliased, computed, qualified-wildcard, or unknown `RETURNING` item;
873921
`RETURNING` on `:exec`; and `UPDATE ... FROM`, `DELETE ... USING`, or a common
874922
table expression in a returning write.
@@ -889,8 +937,10 @@ them:
889937
than a placeholder, such as `id = ANY('{1,2}')`,
890938
- common table expressions, and subqueries outside the `FROM` item,
891939
- `UPDATE ... FROM` and `DELETE ... USING` on a non-returning `:exec` write,
892-
- the unique index an accepted `ON CONFLICT` target matches, and the column
893-
references of an `EXCLUDED` value that is not exactly `EXCLUDED.column`.
940+
- the unique index an accepted `ON CONFLICT` target matches, the constraint an
941+
`ON CONSTRAINT` target names, the column references of an `EXCLUDED` value
942+
that is not exactly `EXCLUDED.column`, and a conflict-predicate column that
943+
no placeholder is compared with, as in a `WHERE` clause.
894944

895945
A clause that is not analyzed still binds the placeholders a cast types inside
896946
it, in textual order, and nothing else of it is checked. A cast placeholder in a
@@ -1159,6 +1209,22 @@ Writes and `RETURNING`:
11591209
executes a `DO NOTHING` and a `DO UPDATE` action, each with and without
11601210
`RETURNING`, over a new row and then a conflicting one against
11611211
PostgreSQL 16.
1212+
- `QueryAnalyzerTest.shouldAnalyzeUpsertWithoutAConflictTarget`,
1213+
`QueryAnalyzerTest.shouldAnalyzeUpsertOnANamedConstraint`,
1214+
`QueryAnalyzerTest.shouldAnalyzeAPlaceholderInAConflictTargetPredicate`,
1215+
`QueryAnalyzerTest.shouldAnalyzeANamedPlaceholderInAConflictTargetPredicate`,
1216+
`QueryAnalyzerTest.shouldAnalyzePlaceholdersInAConflictUpdatePredicate`,
1217+
`QueryAnalyzerTest.shouldAnalyzeAConflictUpdatePredicateThroughTheInsertedTableAlias`,
1218+
`QueryAnalyzerTest.shouldBindConflictPredicatesInTextualOrder`, and
1219+
`QueryAnalyzerTest.shouldRejectAConflictPredicateColumnOutsideItsScope` cover
1220+
the conflict forms that name no column target: a targetless `DO NOTHING`, a
1221+
named constraint with either action, both predicates and the scope each one
1222+
reads, their placeholder types and names, their textual binding order, and
1223+
the ambiguous and out-of-scope predicate columns, and
1224+
`PostgresIntegrationTest.shouldExecuteGeneratedConflictClausesAgainstPostgres`
1225+
executes each of those forms against PostgreSQL 16, including a target
1226+
predicate that selects a partial unique index and a `DO UPDATE ... WHERE`
1227+
that updates once and then skips the row.
11621228
- `QueryAnalyzerTest.shouldResolveRowTableForReturningAllColumns` covers the
11631229
returning writes that produce a complete table row.
11641230
- `PostgresIntegrationTest.shouldExecuteGeneratedWriteAgainstPostgres`,

‎sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java‎

Lines changed: 54 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ private QueryModel analyzeInsert(Query query, ParsedSql parsedSql, Insert insert
374374
Placeholders placeholders = toPlaceholders(parsedSql, schema);
375375

376376
resolveInsertParameters(insert, source.table(), placeholders);
377-
resolveConflictParameters(insert, source.table(), placeholders);
377+
resolveConflictParameters(insert, source, placeholders);
378378

379379
return toQueryModel(
380380
query,
@@ -711,13 +711,19 @@ private void resolveUpdateSetParameters(
711711

712712
/**
713713
* Resolves the conflict clause of an insert, whose placeholders are written
714-
* after the {@code VALUES} placeholders and therefore bind after them. Only
715-
* a target of plain column names of the inserted table is modelled;
716-
* PostgreSQL still decides which unique index that target matches.
714+
* after the {@code VALUES} placeholders and therefore bind after them. A
715+
* {@code DO UPDATE ... WHERE} predicate reads both the stored row and the
716+
* proposed one, so its placeholders follow the {@code WHERE} rules over the
717+
* inserted table and {@code EXCLUDED} as two sources of the same columns,
718+
* which makes an unqualified column of either one ambiguous, as PostgreSQL
719+
* also reports it.
720+
*
721+
* <p>{@code DO NOTHING} decides the conflict by itself, so it needs no
722+
* target, while {@code DO UPDATE} must name the rows it updates.
717723
*/
718724
private void resolveConflictParameters(
719725
Insert insert,
720-
dev.sqlcj.schema.Table table,
726+
Source source,
721727
Placeholders placeholders
722728
) {
723729
InsertConflictTarget target = insert.getConflictTarget();
@@ -727,56 +733,72 @@ private void resolveConflictParameters(
727733
return;
728734
}
729735

730-
resolveConflictTarget(target, table);
736+
boolean doUpdate = action != null
737+
&& action.getConflictActionType() == ConflictActionType.DO_UPDATE;
731738

732-
if (action == null || action.getConflictActionType() != ConflictActionType.DO_UPDATE) {
733-
return;
734-
}
735-
736-
if (action.getWhereExpression() != null) {
739+
if (target != null) {
740+
resolveConflictTarget(target, source, placeholders);
741+
} else if (doUpdate) {
737742
throw new UnsupportedOperationException(
738-
"ON CONFLICT DO UPDATE ... WHERE is not supported."
743+
"ON CONFLICT DO UPDATE requires a conflict target,"
744+
+ " such as ON CONFLICT (id) or ON CONFLICT ON CONSTRAINT name."
739745
);
740746
}
741747

748+
if (!doUpdate) {
749+
return;
750+
}
751+
742752
List<UpdateSet> updateSets = action.getUpdateSets();
743753

744-
resolveUpdateSetParameters(updateSets, table, placeholders);
745-
resolveExcludedColumns(updateSets, table);
754+
resolveUpdateSetParameters(updateSets, source.table(), placeholders);
755+
resolveExcludedColumns(updateSets, source.table());
756+
757+
if (action.getWhereExpression() != null) {
758+
resolveParameters(
759+
action.getWhereExpression(),
760+
List.of(source, new Source(EXCLUDED_QUALIFIER, source.table(), false)),
761+
placeholders
762+
);
763+
}
746764
}
747765

748766
/**
749-
* Resolves the conflict target against the inserted table. A target this
750-
* subset does not model — an absent one, a named constraint, or an element
751-
* that is an expression or carries a collation or an operator class —
752-
* decides the conflict by something the analyzed model does not describe.
767+
* Resolves the conflict target against the inserted table. Each element of
768+
* a target of plain column names is a column of that table, and the
769+
* target's predicate reads that table alone, under the name it exposes. A named
770+
* constraint, like the unique index a column-name target matches, is
771+
* resolved by PostgreSQL. An element that is an expression or carries a
772+
* collation or an operator class decides the conflict by something the
773+
* analyzed model does not describe.
753774
*/
754775
private void resolveConflictTarget(
755776
InsertConflictTarget target,
756-
dev.sqlcj.schema.Table table
777+
Source source,
778+
Placeholders placeholders
757779
) {
758-
boolean columnNames = target != null
759-
&& target.getConstraintName() == null
760-
&& target.getIndexElements().stream().noneMatch(
761-
element -> element.isExpression()
762-
|| element.getCollation() != null
763-
|| element.getOperatorClass() != null
764-
);
780+
if (target.getConstraintName() != null) {
781+
return;
782+
}
783+
784+
boolean columnNames = target.getIndexElements().stream().noneMatch(
785+
element -> element.isExpression()
786+
|| element.getCollation() != null
787+
|| element.getOperatorClass() != null
788+
);
765789

766790
if (!columnNames) {
767791
throw new UnsupportedOperationException(
768792
"ON CONFLICT requires a target of column names, such as ON CONFLICT (id)."
769793
);
770794
}
771795

772-
if (target.getWhereExpression() != null) {
773-
throw new UnsupportedOperationException(
774-
"ON CONFLICT target predicates are not supported."
775-
);
796+
for (Index.ColumnParams element : target.getIndexElements()) {
797+
findColumn(source.table(), MultiPartName.unquote(element.getColumnName()));
776798
}
777799

778-
for (Index.ColumnParams element : target.getIndexElements()) {
779-
findColumn(table, MultiPartName.unquote(element.getColumnName()));
800+
if (target.getWhereExpression() != null) {
801+
resolveParameters(target.getWhereExpression(), List.of(source), placeholders);
780802
}
781803
}
782804

0 commit comments

Comments
 (0)