Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 82 additions & 8 deletions docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand All @@ -372,9 +419,10 @@ the target table's shared `<TableName>Row` record, while a `RETURNING` column
list keeps the query's own `<QueryName>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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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`,
Expand Down
125 changes: 112 additions & 13 deletions sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
}

Expand Down Expand Up @@ -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<UpdateSet> 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."
Expand All @@ -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<UpdateSet> 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<UpdateSet> 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
Expand Down
Loading
Loading