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`, 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