Skip to content

Commit 416a23c

Browse files
authored
Merge pull request #150 from nubo-db/fix/partiql-update-not-upsert
2 parents caeb631 + af1d52e commit 416a23c

3 files changed

Lines changed: 39 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
- PartiQL `UPDATE` now performs real list-index writes. `SET tags[0] = :v` updates the list element (appending when the index is at or beyond the end) and `REMOVE tags[0]` deletes it and shifts the rest, where dynoxide previously treated `tags[0]` as a literal map key so both the stored item and a `RETURNING MODIFIED` projection over it diverged from DynamoDB. A `RETURNING MODIFIED` projection over list-index paths now packs the changed elements into a dense list in ascending index order (`SET a[0], a[2]` yields `{a: [v0, v2]}`), matching DynamoDB.
2222
- `BatchExecuteStatement` now echoes `TableName` on each successful member response, and a member that fails to parse now carries the short-form `ValidationError` code (matching a per-statement execution error) instead of the long-form `ValidationException`. Both match DynamoDB.
23+
- PartiQL `UPDATE` on a non-existent key now fails with `ConditionalCheckFailedException` (`The conditional request failed`) and creates nothing, where dynoxide upserted the item. `UPDATE` is not an upsert: the target must already exist, matching DynamoDB.
2324
- PartiQL parse errors now use DynamoDB's message wording: `Statement wasn't well formed, can't be processed: <detail>` (previously `... got error: ...`), and a statement that does not begin with a DML keyword reports `Expected data manipulation`. Applies to `ExecuteStatement`, `BatchExecuteStatement`, and `ExecuteTransaction`.
2425

2526
## [0.11.4] - 2026-07-17

src/partiql/executor.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -430,11 +430,11 @@ async fn execute_update<S: StorageBackend>(
430430

431431
let old_item = item.clone();
432432

433-
// Non-key WHERE predicates act as a condition on the existing item, like a
434-
// conditional write. When the item exists but the condition is false, AWS
435-
// raises ConditionalCheckFailedException; a missing item is not a condition
436-
// failure and falls through to the existing create/no-op behaviour below.
437-
if existing_json.is_some() && !matches_where(&old_item, where_clause, parameters) {
433+
// PartiQL UPDATE is not an upsert: the target item must already exist, so a
434+
// missing item fails ConditionalCheckFailedException and creates nothing.
435+
// Non-key WHERE predicates act as a further condition on the existing item;
436+
// if that predicate is false the update fails the same way. Neither writes.
437+
if existing_json.is_none() || !matches_where(&old_item, where_clause, parameters) {
438438
return Err(DynoxideError::ConditionalCheckFailedException(
439439
"The conditional request failed".to_string(),
440440
None,

tests/partiql.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,39 @@ fn test_update_adds_new_attribute() {
281281
);
282282
}
283283

284+
#[test]
285+
fn test_update_on_missing_key_is_not_an_upsert() {
286+
let db = Database::memory().unwrap();
287+
create_test_table(&db, "Users");
288+
// The key is deliberately not seeded.
289+
290+
// PartiQL UPDATE requires the item to exist: a missing key fails
291+
// ConditionalCheckFailedException rather than creating the item. This holds
292+
// with or without a RETURNING clause.
293+
for statement in [
294+
"UPDATE \"Users\" SET data = 'new' WHERE pk = 'ghost'",
295+
"UPDATE \"Users\" SET data = 'new' WHERE pk = 'ghost' RETURNING ALL OLD *",
296+
] {
297+
let err = db
298+
.execute_statement(ExecuteStatementRequest {
299+
statement: statement.to_string(),
300+
parameters: None,
301+
..Default::default()
302+
})
303+
.unwrap_err();
304+
match err {
305+
DynoxideError::ConditionalCheckFailedException(msg, _) => {
306+
assert_eq!(msg, "The conditional request failed", "for `{statement}`")
307+
}
308+
other => panic!("expected ConditionalCheckFailedException, got {other:?}"),
309+
}
310+
}
311+
312+
// The rejected updates did not create the item.
313+
let sel = exec(&db, "SELECT * FROM \"Users\" WHERE pk = 'ghost'");
314+
assert!(sel.items.unwrap().is_empty());
315+
}
316+
284317
// -----------------------------------------------------------------------
285318
// DELETE
286319
// -----------------------------------------------------------------------

0 commit comments

Comments
 (0)