Skip to content

Commit ce5416f

Browse files
authored
Merge pull request #59 from sqlcj-dev/feature/insert-on-conflict-upserts
feat(analysis): accept ON CONFLICT upserts on inserts
2 parents 36d4028 + 56bf061 commit ce5416f

4 files changed

Lines changed: 637 additions & 22 deletions

File tree

‎docs/queries.md‎

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ A write targets exactly one table of its entry's schema.
295295

296296
| Statement | Accepted shape |
297297
| --- | --- |
298-
| `INSERT` | an explicit column list and a single `VALUES` row, each value a placeholder or an expression that binds none |
298+
| `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 |
299299
| `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 |
300300
| `DELETE` | one target table with an optional `WHERE` using the read predicate forms |
301301

@@ -356,6 +356,53 @@ the whole value, as in `SET bio = :bio::text`, is typed by its cast as well and
356356
named after its column. A value that contains an uncast placeholder, such as
357357
`COALESCE($2, bio)`, stays rejected.
358358

359+
### Upsert with `ON CONFLICT`
360+
361+
An `INSERT` may end with one conflict clause whose target is a parenthesized
362+
list of plain column names of the inserted table, followed by one action:
363+
364+
| Action | Accepted shape |
365+
| --- | --- |
366+
| `DO NOTHING` | no assignment, so the statement binds only its `VALUES` row |
367+
| `DO UPDATE SET` | assignments analyzed exactly as `UPDATE` assignments are, where a value may also be `EXCLUDED.column` |
368+
369+
```sql
370+
-- name: UpsertAuthor :one
371+
INSERT INTO authors (id, name, bio)
372+
VALUES (:id, :name, :bio)
373+
ON CONFLICT (id) DO UPDATE
374+
SET name = EXCLUDED.name,
375+
bio = :bio,
376+
version = authors.version + 1,
377+
updated_at = now()
378+
RETURNING *;
379+
```
380+
381+
`UpsertAuthor` generates `upsertAuthor(Long id, String name, String bio)` and
382+
returns the `AuthorsRow` it inserted or updated.
383+
384+
A `DO UPDATE` placeholder is written after the `VALUES` placeholders, so it
385+
binds after them: on a two-value insert,
386+
`ON CONFLICT (id) DO UPDATE SET active = $3` binds `$1`, `$2`, and `$3` in that
387+
order, and the repeated `:bio` above is bound at both of its positions. A
388+
`DO UPDATE` placeholder is typed by its assigned column, or by its own cast
389+
wherever a cast appears inside the assigned value.
390+
391+
`EXCLUDED.column` reads the proposed row, and that column must exist in the
392+
inserted table. An `EXCLUDED` reference inside a larger expression is not
393+
resolved, as no other computed write value is, so PostgreSQL checks it. sqlcj
394+
does not match the conflict target against a unique index; PostgreSQL does.
395+
396+
A `DO NOTHING` action writes no row when a conflict occurs, so
397+
`DO NOTHING ... RETURNING` returns no row on conflict and suits `:optional` or
398+
`:many` rather than `:one`. A `DO UPDATE ... RETURNING` always returns the row
399+
it inserted or updated.
400+
401+
Every other conflict form is rejected on every insert, whether `:exec` or
402+
returning: a missing target, `ON CONFLICT ON CONSTRAINT`, a target element that
403+
is an expression or carries a collation or an operator class, a conflict-target
404+
predicate, and `DO UPDATE ... WHERE`.
405+
359406
### `RETURNING`
360407

361408
A supported `INSERT`, `UPDATE`, or `DELETE` declared `:one`, `:optional`, or
@@ -372,9 +419,10 @@ the target table's shared `<TableName>Row` record, while a `RETURNING` column
372419
list keeps the query's own `<QueryName>Result` record.
373420

374421
An aliased or computed `RETURNING` item, a qualified `table.*`, an unknown
375-
column, and `RETURNING` on `:exec` are rejected. `ON CONFLICT`, multi-row
376-
`VALUES`, `INSERT ... SELECT`, `UPDATE ... FROM`, `DELETE ... USING`, and common
377-
table expressions are rejected in a returning write.
422+
column, and `RETURNING` on `:exec` are rejected. Multi-row `VALUES`,
423+
`INSERT ... SELECT`, `UPDATE ... FROM`, `DELETE ... USING`, and common table
424+
expressions are rejected in a returning write, while an accepted `ON CONFLICT`
425+
clause is analyzed the same way with and without `RETURNING`.
378426

379427
## Parameters
380428

@@ -721,9 +769,16 @@ name, and its header line.
721769
or built from a `SELECT`; an `UPDATE` assignment that sets a column list, such
722770
as `SET (name, active) = ('a', TRUE)`; and a written column that the table does
723771
not declare.
772+
- A conflict clause outside the accepted upsert shape, on an `:exec` and on a
773+
returning insert alike: `ON CONFLICT` without a target, `ON CONFLICT ON
774+
CONSTRAINT`, a target element that is an expression or carries a collation or
775+
an operator class, a conflict-target predicate such as
776+
`ON CONFLICT (id) WHERE active`, and `DO UPDATE ... WHERE`. A `DO UPDATE`
777+
assignment that sets a column list, and an unknown target, assigned, or
778+
`EXCLUDED` column, are rejected as the write forms above are.
724779
- An aliased, computed, qualified-wildcard, or unknown `RETURNING` item;
725-
`RETURNING` on `:exec`; and `ON CONFLICT`, `UPDATE ... FROM`,
726-
`DELETE ... USING`, or a common table expression in a returning write.
780+
`RETURNING` on `:exec`; and `UPDATE ... FROM`, `DELETE ... USING`, or a common
781+
table expression in a returning write.
727782
- Any annotation other than `:one`, `:optional`, `:many`, and `:exec`.
728783

729784
### Not analyzed
@@ -739,8 +794,9 @@ them:
739794
literal pattern, a range whose bounds are both literal such as
740795
`id BETWEEN 1 AND 10`, or `IN` with a subquery,
741796
- common table expressions, and subqueries outside the `FROM` item,
742-
- `ON CONFLICT`, `UPDATE ... FROM`, and `DELETE ... USING` on a non-returning
743-
`:exec` write.
797+
- `UPDATE ... FROM` and `DELETE ... USING` on a non-returning `:exec` write,
798+
- the unique index an accepted `ON CONFLICT` target matches, and the column
799+
references of an `EXCLUDED` value that is not exactly `EXCLUDED.column`.
744800

745801
sqlcj itself provides no macros, array operators such as `= ANY`, dynamic `IN`
746802
expansion, or query-building API. An array parameter is one whole list bound at
@@ -916,6 +972,24 @@ Writes and `RETURNING`:
916972
`QueryAnalyzerTest.shouldRejectExcludedWriteValueForm` cover the values that
917973
bind no placeholder, their numbering and binding order, and the nearby
918974
rejections.
975+
- `QueryAnalyzerTest.shouldAnalyzeUpsertThatUpdatesOnConflict`,
976+
`QueryAnalyzerTest.shouldAnalyzeReturningUpsertThatUpdatesOnConflict`,
977+
`QueryAnalyzerTest.shouldAnalyzeNamedUpsertThatUpdatesOnConflict`,
978+
`QueryAnalyzerTest.shouldAnalyzeUpsertThatDoesNothingOnConflict`,
979+
`QueryAnalyzerTest.shouldResolveRowTableForUpsertThatDoesNothingOnConflict`,
980+
`QueryAnalyzerTest.shouldRejectAConflictTargetThatIsNotColumnNames`,
981+
`QueryAnalyzerTest.shouldRejectExcludedConflictForm`,
982+
`QueryAnalyzerTest.shouldRejectAnUncastPlaceholderInsideAConflictUpdateValue`,
983+
and `QueryAnalyzerTest.shouldRejectAnUnknownConflictColumn` cover the accepted
984+
conflict target, both actions, the `EXCLUDED`, placeholder, cast, and
985+
computed assignments, the binding order after the `VALUES` placeholders, the
986+
returning row type, and the conflict forms, unaccounted placeholder, and
987+
unknown target, assigned, and `EXCLUDED` columns that are rejected on an
988+
`:exec` and on a returning insert alike, and
989+
`PostgresIntegrationTest.shouldExecuteGeneratedUpsertsAgainstPostgres`
990+
executes a `DO NOTHING` and a `DO UPDATE` action, each with and without
991+
`RETURNING`, over a new row and then a conflicting one against
992+
PostgreSQL 16.
919993
- `QueryAnalyzerTest.shouldResolveRowTableForReturningAllColumns` covers the
920994
returning writes that produce a complete table row.
921995
- `PostgresIntegrationTest.shouldExecuteGeneratedWriteAgainstPostgres`,

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

Lines changed: 112 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,16 @@
3030
import net.sf.jsqlparser.expression.operators.relational.MinorThanEquals;
3131
import net.sf.jsqlparser.expression.operators.relational.NotEqualsTo;
3232
import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList;
33+
import net.sf.jsqlparser.schema.MultiPartName;
3334
import net.sf.jsqlparser.schema.Table;
3435
import net.sf.jsqlparser.statement.ReturningClause;
3536
import net.sf.jsqlparser.statement.Statement;
37+
import net.sf.jsqlparser.statement.create.table.Index;
3638
import net.sf.jsqlparser.statement.delete.Delete;
39+
import net.sf.jsqlparser.statement.insert.ConflictActionType;
3740
import net.sf.jsqlparser.statement.insert.Insert;
41+
import net.sf.jsqlparser.statement.insert.InsertConflictAction;
42+
import net.sf.jsqlparser.statement.insert.InsertConflictTarget;
3843
import net.sf.jsqlparser.statement.select.AllColumns;
3944
import net.sf.jsqlparser.statement.select.AllTableColumns;
4045
import net.sf.jsqlparser.statement.select.Join;
@@ -88,6 +93,13 @@ public final class QueryAnalyzer {
8893
*/
8994
private static final String INDEXED_PARAMETER_NAME_PREFIX = "param";
9095

96+
/**
97+
* The qualifier an {@code ON CONFLICT DO UPDATE} assignment writes to read
98+
* a column of the row the insert proposed, compared ignoring case as
99+
* PostgreSQL resolves it.
100+
*/
101+
private static final String EXCLUDED_QUALIFIER = "EXCLUDED";
102+
91103
/**
92104
* Resolves the Java type of parameter occurrence, which decides whether a
93105
* repeated placeholder index can share one generated parameter.
@@ -325,6 +337,7 @@ private QueryModel analyzeInsert(Query query, ParsedSql parsedSql, Insert insert
325337
Placeholders placeholders = toPlaceholders(parsedSql, schema);
326338

327339
resolveInsertParameters(insert, source.table(), placeholders);
340+
resolveConflictParameters(insert, source.table(), placeholders);
328341

329342
return toQueryModel(
330343
query,
@@ -356,7 +369,7 @@ private QueryModel analyzeUpdate(Query query, ParsedSql parsedSql, Update update
356369

357370
Placeholders placeholders = toPlaceholders(parsedSql, schema);
358371

359-
resolveUpdateSetParameters(update, source.table(), placeholders);
372+
resolveUpdateSetParameters(update.getUpdateSets(), source.table(), placeholders);
360373

361374
if (update.getWhere() != null) {
362375
resolveParameters(update.getWhere(), List.of(source), placeholders);
@@ -545,12 +558,6 @@ private void requireSupportedReturningInsert(Insert insert) {
545558
);
546559
}
547560

548-
if (insert.getConflictTarget() != null || insert.getConflictAction() != null) {
549-
throw new UnsupportedOperationException(
550-
"INSERT ... ON CONFLICT is not supported."
551-
);
552-
}
553-
554561
requireNoCommonTableExpressions(insert.getWithItemsList());
555562
}
556563

@@ -637,17 +644,18 @@ private ParenthesedExpressionList<?> resolveInsertValues(Insert insert) {
637644
}
638645

639646
/**
640-
* Resolves the {@code UPDATE} assignment parameters in source order, which
641-
* precedes any parameter in the {@code WHERE} expression. An assigned value
642-
* that binds no placeholder reaches the database as written, so only its
643-
* target column must exist.
647+
* Resolves the assignment parameters of an {@code UPDATE} or of an
648+
* {@code ON CONFLICT DO UPDATE} action in source order, which precedes any
649+
* parameter in the {@code WHERE} expression. An assigned value that binds
650+
* no placeholder reaches the database as written, so only its target
651+
* column must exist.
644652
*/
645653
private void resolveUpdateSetParameters(
646-
Update update,
654+
List<UpdateSet> updateSets,
647655
dev.sqlcj.schema.Table table,
648656
Placeholders placeholders
649657
) {
650-
for (UpdateSet updateSet : update.getUpdateSets()) {
658+
for (UpdateSet updateSet : updateSets) {
651659
if (updateSet.getColumns().size() != 1 || updateSet.getValues().size() != 1) {
652660
throw new UnsupportedOperationException(
653661
"UPDATE assignments must set one column at a time."
@@ -664,6 +672,97 @@ private void resolveUpdateSetParameters(
664672
}
665673
}
666674

675+
/**
676+
* Resolves the conflict clause of an insert, whose placeholders are written
677+
* after the {@code VALUES} placeholders and therefore bind after them. Only
678+
* a target of plain column names of the inserted table is modelled;
679+
* PostgreSQL still decides which unique index that target matches.
680+
*/
681+
private void resolveConflictParameters(
682+
Insert insert,
683+
dev.sqlcj.schema.Table table,
684+
Placeholders placeholders
685+
) {
686+
InsertConflictTarget target = insert.getConflictTarget();
687+
InsertConflictAction action = insert.getConflictAction();
688+
689+
if (target == null && action == null) {
690+
return;
691+
}
692+
693+
resolveConflictTarget(target, table);
694+
695+
if (action == null || action.getConflictActionType() != ConflictActionType.DO_UPDATE) {
696+
return;
697+
}
698+
699+
if (action.getWhereExpression() != null) {
700+
throw new UnsupportedOperationException(
701+
"ON CONFLICT DO UPDATE ... WHERE is not supported."
702+
);
703+
}
704+
705+
List<UpdateSet> updateSets = action.getUpdateSets();
706+
707+
resolveUpdateSetParameters(updateSets, table, placeholders);
708+
resolveExcludedColumns(updateSets, table);
709+
}
710+
711+
/**
712+
* Resolves the conflict target against the inserted table. A target this
713+
* subset does not model — an absent one, a named constraint, or an element
714+
* that is an expression or carries a collation or an operator class —
715+
* decides the conflict by something the analyzed model does not describe.
716+
*/
717+
private void resolveConflictTarget(
718+
InsertConflictTarget target,
719+
dev.sqlcj.schema.Table table
720+
) {
721+
boolean columnNames = target != null
722+
&& target.getConstraintName() == null
723+
&& target.getIndexElements().stream().noneMatch(
724+
element -> element.isExpression()
725+
|| element.getCollation() != null
726+
|| element.getOperatorClass() != null
727+
);
728+
729+
if (!columnNames) {
730+
throw new UnsupportedOperationException(
731+
"ON CONFLICT requires a target of column names, such as ON CONFLICT (id)."
732+
);
733+
}
734+
735+
if (target.getWhereExpression() != null) {
736+
throw new UnsupportedOperationException(
737+
"ON CONFLICT target predicates are not supported."
738+
);
739+
}
740+
741+
for (Index.ColumnParams element : target.getIndexElements()) {
742+
findColumn(table, MultiPartName.unquote(element.getColumnName()));
743+
}
744+
}
745+
746+
/**
747+
* Resolves the column each {@code DO UPDATE} value that is exactly
748+
* {@code EXCLUDED.column} reads from the row the insert proposed, which is
749+
* a column of the inserted table. A value that merely contains such a
750+
* reference is not analyzed, as no other computed write value is.
751+
*/
752+
private void resolveExcludedColumns(
753+
List<UpdateSet> updateSets,
754+
dev.sqlcj.schema.Table table
755+
) {
756+
for (UpdateSet updateSet : updateSets) {
757+
if (
758+
updateSet.getValue(0) instanceof net.sf.jsqlparser.schema.Column column
759+
&& EXCLUDED_QUALIFIER.equalsIgnoreCase(qualifier(column))
760+
) {
761+
findColumn(table, column.getUnquotedColumnName());
762+
}
763+
}
764+
}
765+
667766
/**
668767
* Builds the analyzed model from the parameter occurrences collected in
669768
* textual order, keeping that order for JDBC binding and exposing one

0 commit comments

Comments
 (0)