Skip to main content

dynoxide/actions/
update_item.rs

1use crate::actions::helpers;
2use crate::errors::{DynoxideError, Result};
3use crate::storage_backend::StorageBackend;
4use crate::types::{self, AttributeValue};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Internal result from the transactional update work closure.
9struct UpdateWorkResult {
10    old_item: HashMap<String, AttributeValue>,
11    item: HashMap<String, AttributeValue>,
12    item_json: String,
13    size: usize,
14}
15
16/// Internal deserialization struct for detecting missing fields.
17#[derive(Debug, Default, Deserialize)]
18struct UpdateItemRequestRaw {
19    #[serde(rename = "TableName", default)]
20    table_name: Option<String>,
21    #[serde(rename = "Key", default)]
22    key: Option<HashMap<String, AttributeValue>>,
23    #[serde(rename = "UpdateExpression", default)]
24    update_expression: Option<String>,
25    #[serde(rename = "ConditionExpression", default)]
26    condition_expression: Option<String>,
27    #[serde(rename = "ExpressionAttributeNames", default)]
28    expression_attribute_names: Option<HashMap<String, String>>,
29    #[serde(rename = "ExpressionAttributeValues", default)]
30    expression_attribute_values: Option<HashMap<String, AttributeValue>>,
31    #[serde(rename = "ReturnValues", default)]
32    return_values: Option<String>,
33    #[serde(rename = "ReturnConsumedCapacity", default)]
34    return_consumed_capacity: Option<String>,
35    #[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
36    return_values_on_condition_check_failure: Option<String>,
37    #[serde(rename = "ReturnItemCollectionMetrics", default)]
38    return_item_collection_metrics: Option<String>,
39    #[serde(rename = "AttributeUpdates", default)]
40    attribute_updates: Option<HashMap<String, AttributeValueUpdate>>,
41    #[serde(rename = "Expected", default)]
42    expected: Option<serde_json::Value>,
43    #[serde(rename = "ConditionalOperator", default)]
44    conditional_operator: Option<String>,
45}
46
47#[derive(Debug, Default)]
48pub struct UpdateItemRequest {
49    pub table_name: String,
50    pub key: HashMap<String, AttributeValue>,
51    pub update_expression: Option<String>,
52    pub condition_expression: Option<String>,
53    pub expression_attribute_names: Option<HashMap<String, String>>,
54    pub expression_attribute_values: Option<HashMap<String, AttributeValue>>,
55    pub return_values: Option<String>,
56    pub return_consumed_capacity: Option<String>,
57    pub return_values_on_condition_check_failure: Option<String>,
58    pub return_item_collection_metrics: Option<String>,
59    pub attribute_updates: Option<HashMap<String, AttributeValueUpdate>>,
60    pub expected: Option<serde_json::Value>,
61    pub conditional_operator: Option<String>,
62}
63
64/// First invalid `Return*` enum for UpdateItem, ReturnValues first, or `None` if
65/// all valid. UpdateItem stops at the first; PutItem aggregates.
66fn first_invalid_return_enum(
67    return_values: Option<&str>,
68    return_consumed_capacity: Option<&str>,
69    return_item_collection_metrics: Option<&str>,
70) -> Option<String> {
71    if let Some(rv) = return_values {
72        if !["ALL_NEW", "UPDATED_OLD", "ALL_OLD", "NONE", "UPDATED_NEW"].contains(&rv) {
73            return Some(format!(
74                "Value '{}' at 'returnValues' failed to satisfy constraint: \
75                 Member must satisfy enum value set: \
76                 [ALL_NEW, UPDATED_OLD, ALL_OLD, NONE, UPDATED_NEW]",
77                rv
78            ));
79        }
80    }
81    if let Some(rcc) = return_consumed_capacity {
82        if !["INDEXES", "TOTAL", "NONE"].contains(&rcc) {
83            return Some(format!(
84                "Value '{}' at 'returnConsumedCapacity' failed to satisfy constraint: \
85                 Member must satisfy enum value set: [INDEXES, TOTAL, NONE]",
86                rcc
87            ));
88        }
89    }
90    if let Some(ricm) = return_item_collection_metrics {
91        if !["SIZE", "NONE"].contains(&ricm) {
92            return Some(format!(
93                "Value '{}' at 'returnItemCollectionMetrics' failed to satisfy constraint: \
94                 Member must satisfy enum value set: [SIZE, NONE]",
95                ricm
96            ));
97        }
98    }
99    None
100}
101
102impl<'de> serde::Deserialize<'de> for UpdateItemRequest {
103    fn deserialize<D: serde::Deserializer<'de>>(
104        deserializer: D,
105    ) -> std::result::Result<Self, D::Error> {
106        let raw = UpdateItemRequestRaw::deserialize(deserializer)?;
107        use crate::validation::{
108            TableNameContext, format_validation_errors, table_name_constraint_errors,
109        };
110
111        // AWS reports an invalid table name on its own, before the key and
112        // Return* enum checks (eu-west-2).
113        let table_name_errors =
114            table_name_constraint_errors(raw.table_name.as_deref(), TableNameContext::ReadWrite);
115        if let Some(msg) = format_validation_errors(&table_name_errors) {
116            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
117        }
118        let table_name = raw.table_name.unwrap_or_default();
119
120        let mut errors = Vec::new();
121
122        // Key constraint
123        if raw.key.is_none() {
124            errors.push(
125                "Value null at 'key' failed to satisfy constraint: \
126                 Member must not be null"
127                    .to_string(),
128            );
129        }
130
131        // UpdateItem stops at the first invalid enum (ReturnValues first) and
132        // reports one error, where PutItem aggregates all of them. Keep separate.
133        if let Some(enum_err) = first_invalid_return_enum(
134            raw.return_values.as_deref(),
135            raw.return_consumed_capacity.as_deref(),
136            raw.return_item_collection_metrics.as_deref(),
137        ) {
138            errors.push(enum_err);
139        }
140
141        if let Some(msg) = format_validation_errors(&errors) {
142            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
143        }
144
145        Ok(UpdateItemRequest {
146            table_name,
147            key: raw.key.unwrap_or_default(),
148            update_expression: raw.update_expression,
149            condition_expression: raw.condition_expression,
150            expression_attribute_names: raw.expression_attribute_names,
151            expression_attribute_values: raw.expression_attribute_values,
152            return_values: raw.return_values,
153            return_consumed_capacity: raw.return_consumed_capacity,
154            return_values_on_condition_check_failure: raw.return_values_on_condition_check_failure,
155            return_item_collection_metrics: raw.return_item_collection_metrics,
156            attribute_updates: raw.attribute_updates,
157            expected: raw.expected,
158            conditional_operator: raw.conditional_operator,
159        })
160    }
161}
162
163/// Legacy `AttributeUpdates` entry — one per attribute being modified.
164#[derive(Debug, Clone, Default, Deserialize)]
165pub struct AttributeValueUpdate {
166    #[serde(rename = "Action", default = "default_put_action")]
167    pub action: String,
168    #[serde(rename = "Value", default)]
169    pub value: Option<AttributeValue>,
170}
171
172fn default_put_action() -> String {
173    "PUT".to_string()
174}
175
176#[derive(Debug, Default, Serialize)]
177pub struct UpdateItemResponse {
178    #[serde(rename = "Attributes", skip_serializing_if = "Option::is_none")]
179    pub attributes: Option<HashMap<String, AttributeValue>>,
180    #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
181    pub consumed_capacity: Option<types::ConsumedCapacity>,
182    #[serde(
183        rename = "ItemCollectionMetrics",
184        skip_serializing_if = "Option::is_none"
185    )]
186    pub item_collection_metrics: Option<crate::types::ItemCollectionMetrics>,
187}
188
189/// Apply the `Invalid UpdateExpression:` prefix to a sub-error message at the
190/// UpdateItem dispatch boundary. AWS DynamoDB tags the missing-EAV error
191/// (and similar UpdateExpression-scoped errors) with this prefix; the prefix
192/// must not leak into ConditionExpression contexts that share the same
193/// underlying validators in `crate::expressions::mod`. Idempotent so that
194/// errors which already carry the prefix (e.g. parser-level syntax errors)
195/// are not double-wrapped.
196fn wrap_invalid_update_expression(err: String) -> String {
197    if err.starts_with("Invalid UpdateExpression:") {
198        err
199    } else {
200        format!("Invalid UpdateExpression: {err}")
201    }
202}
203
204pub async fn execute<S: StorageBackend>(
205    storage: &S,
206    request: UpdateItemRequest,
207) -> Result<UpdateItemResponse> {
208    // Apply the request-validation envelope exactly once at the operation
209    // boundary, so every early return inside is covered and the tagged
210    // variant never escapes.
211    execute_inner(storage, request)
212        .await
213        .map_err(crate::validation::envelope_request_validation)
214}
215
216async fn execute_inner<S: StorageBackend>(
217    storage: &S,
218    mut request: UpdateItemRequest,
219) -> Result<UpdateItemResponse> {
220    // Validate table name format before checking existence (DynamoDB validates input first)
221    crate::validation::validate_table_name(&request.table_name)?;
222
223    // Reject {NULL: false} up front. The HTTP path rejects it during request
224    // deserialisation; this keeps the in-process API in agreement (eu-west-2).
225    helpers::validate_no_null_false(&request.key)?;
226    if let Some(ref values) = request.expression_attribute_values {
227        helpers::validate_no_null_false(values)?;
228    }
229    if let Some(ref updates) = request.attribute_updates {
230        for update in updates.values() {
231            if let Some(ref value) = update.value {
232                helpers::validate_no_null_false_value(value)?;
233            }
234        }
235    }
236
237    // Validate expression/non-expression parameter conflicts BEFORE Expected conversion
238    {
239        let mut non_expr = Vec::new();
240        let mut expr_params = Vec::new();
241        if request.attribute_updates.is_some() {
242            non_expr.push("AttributeUpdates");
243        }
244        if request.expected.is_some() {
245            non_expr.push("Expected");
246        }
247        if request.update_expression.is_some() {
248            expr_params.push("UpdateExpression");
249        }
250        if request.condition_expression.is_some() {
251            expr_params.push("ConditionExpression");
252        }
253        let no_raw_eav: Option<serde_json::Value> = None;
254        let ctx = helpers::ExpressionParamContext {
255            non_expression_params: non_expr,
256            expression_params: expr_params,
257            all_expression_param_names: vec!["UpdateExpression", "ConditionExpression"],
258            expression_attribute_names: &request.expression_attribute_names,
259            expression_attribute_values: &request.expression_attribute_values,
260            expression_attribute_values_raw: &no_raw_eav,
261        };
262        // Enveloped families are tagged for the request-validation envelope.
263        helpers::validate_expression_params(&ctx)
264            .map_err(crate::validation::ClassifiedValidationError::into_tagged)?;
265    }
266
267    // Validate key attribute values (unsupported datatypes, invalid numbers)
268    crate::validation::validate_key_attribute_values(&request.key)?;
269
270    // Validate legacy AttributeUpdates parameters
271    if request.update_expression.is_none() {
272        if let Some(ref updates) = request.attribute_updates {
273            for (attr_name, update) in updates {
274                let action = update.action.to_uppercase();
275                if update.value.is_none() && action != "DELETE" {
276                    return Err(DynoxideError::ValidationException(
277                        "One or more parameter values were invalid: \
278                         Only DELETE action is allowed when no attribute value is specified"
279                            .to_string(),
280                    ));
281                }
282                if action == "DELETE" {
283                    if let Some(ref val) = update.value {
284                        let type_name = match val {
285                            AttributeValue::SS(_)
286                            | AttributeValue::NS(_)
287                            | AttributeValue::BS(_) => None,
288                            _ => Some(val.type_name()),
289                        };
290                        if let Some(tn) = type_name {
291                            return Err(DynoxideError::ValidationException(format!(
292                                "One or more parameter values were invalid: \
293                                 DELETE action with value is not supported for the type {tn}"
294                            )));
295                        }
296                    }
297                }
298                if action == "ADD" {
299                    if let Some(ref val) = update.value {
300                        let allowed = matches!(
301                            val,
302                            AttributeValue::N(_)
303                                | AttributeValue::SS(_)
304                                | AttributeValue::NS(_)
305                                | AttributeValue::BS(_)
306                                | AttributeValue::L(_)
307                        );
308                        if !allowed {
309                            let tn = val.type_name();
310                            return Err(DynoxideError::ValidationException(format!(
311                                "One or more parameter values were invalid: \
312                                 ADD action is not supported for the type {tn}"
313                            )));
314                        }
315                    }
316                }
317                let _ = attr_name; // suppress unused warning
318            }
319        }
320    }
321
322    // Validate legacy Expected parameter
323    if request.condition_expression.is_none() && request.update_expression.is_none() {
324        if let Some(ref expected_val) = request.expected {
325            if let Ok(expected) = serde_json::from_value::<
326                HashMap<String, helpers::ExpectedCondition>,
327            >(expected_val.clone())
328            {
329                helpers::validate_expected_conditions(&expected)?;
330            }
331        }
332    }
333
334    // Validate empty UpdateExpression
335    if let Some(ref ue) = request.update_expression {
336        if ue.is_empty() {
337            return Err(DynoxideError::ValidationException(
338                "Invalid UpdateExpression: The expression can not be empty;".to_string(),
339            ));
340        }
341    }
342
343    // Validate empty ConditionExpression
344    if let Some(ref ce) = request.condition_expression {
345        if ce.is_empty() {
346            return Err(DynoxideError::ValidationException(
347                "Invalid ConditionExpression: The expression can not be empty;".to_string(),
348            ));
349        }
350    }
351
352    // Pre-validate UpdateExpression syntax BEFORE table lookup.
353    // DynamoDB validates expression syntax, reserved keywords, undefined attribute
354    // names/values, overlapping paths, etc. before checking table existence.
355    // Parse-time errors here are families DynamoDB wraps in the
356    // request-validation envelope, so they are tagged; the unused-attribute
357    // check propagates bare, matching PutItem.
358    if let Some(ref ue) = request.update_expression {
359        let parsed =
360            crate::expressions::update::parse(ue).map_err(DynoxideError::EnvelopedValidation)?;
361
362        // Track all attribute name/value references statically (without evaluating)
363        let tracker = crate::expressions::TrackedExpressionAttributes::new(
364            &request.expression_attribute_names,
365            &request.expression_attribute_values,
366        );
367        crate::expressions::update::track_references(&parsed, &tracker)
368            .map_err(|e| DynoxideError::EnvelopedValidation(wrap_invalid_update_expression(e)))?;
369
370        // Also walk the ConditionExpression to track its attribute usage
371        if let Some(ref ce) = request.condition_expression {
372            if let Ok(cond_parsed) = crate::expressions::condition::parse(ce) {
373                crate::expressions::condition::track_references(&cond_parsed, &tracker)
374                    .map_err(DynoxideError::EnvelopedValidation)?;
375            }
376        }
377
378        // Check for unused expression attribute names/values
379        tracker.check_unused()?;
380    }
381
382    // Statically validate ConditionExpression (syntax + BETWEEN bounds, etc.)
383    // before table lookup. Parse-time, so tagged like the block above.
384    if let Some(ref ce) = request.condition_expression {
385        let parsed = crate::expressions::condition::parse(ce).map_err(|e| {
386            DynoxideError::EnvelopedValidation(format!("Invalid ConditionExpression: {e}"))
387        })?;
388        crate::expressions::condition::validate_static(
389            &parsed,
390            &request.expression_attribute_values,
391        )
392        .map_err(DynoxideError::EnvelopedValidation)?;
393        crate::expressions::condition::validate_operand_semantics(
394            &parsed,
395            &request.expression_attribute_names,
396            &request.expression_attribute_values,
397        )
398        .map_err(|e| {
399            DynoxideError::EnvelopedValidation(format!("Invalid ConditionExpression: {e}"))
400        })?;
401    }
402
403    // Convert legacy Expected parameter to ConditionExpression if no expression is set
404    if request.condition_expression.is_none() {
405        if let Some(ref expected_val) = request.expected {
406            if let Ok(expected) = serde_json::from_value::<
407                HashMap<String, helpers::ExpectedCondition>,
408            >(expected_val.clone())
409            {
410                if !expected.is_empty() {
411                    let (cond_expr, values) = helpers::convert_expected_to_condition(
412                        &expected,
413                        request.conditional_operator.as_deref(),
414                    )?;
415                    if !cond_expr.is_empty() {
416                        let names = helpers::expected_attr_names(&expected);
417                        request.condition_expression = Some(cond_expr);
418                        let expr_values = request
419                            .expression_attribute_values
420                            .get_or_insert_with(HashMap::new);
421                        expr_values.extend(values);
422                        let expr_names = request
423                            .expression_attribute_names
424                            .get_or_insert_with(HashMap::new);
425                        expr_names.extend(names);
426                    }
427                }
428            }
429        }
430    }
431
432    let meta = helpers::require_table_for_item_op(storage, &request.table_name).await?;
433    let key_schema = helpers::parse_key_schema(&meta)?;
434
435    // Validate ReturnValues parameter. The constraint text is tagged without
436    // the envelope prefix; the operation boundary applies it, so the prefix
437    // has a single producer. The enum list mirrors first_invalid_return_enum's
438    // ordering so the in-process message matches the request deserialiser.
439    if let Some(ref rv) = request.return_values {
440        let rv_upper = rv.to_uppercase();
441        if !["NONE", "ALL_OLD", "ALL_NEW", "UPDATED_OLD", "UPDATED_NEW"]
442            .contains(&rv_upper.as_str())
443        {
444            return Err(DynoxideError::EnvelopedValidation(format!(
445                "Value '{rv}' at 'returnValues' failed to satisfy constraint: \
446                 Member must satisfy enum value set: \
447                 [ALL_NEW, UPDATED_OLD, ALL_OLD, NONE, UPDATED_NEW]"
448            )));
449        }
450    }
451
452    // Validate key
453    helpers::validate_key_only(&request.key, &key_schema)?;
454
455    // Extract key values
456    // TODO: validation must precede this call -- if reaching this line, caller has already validated keys.
457    let (pk, sk) = helpers::extract_key_strings(&request.key, &key_schema)?;
458
459    // Collect the set of attribute names affected by the legacy AttributeUpdates
460    // parameter, used later for UPDATED_OLD / UPDATED_NEW extraction.
461    let legacy_attr_names: Option<Vec<String>> = request
462        .attribute_updates
463        .as_ref()
464        .map(|updates| updates.keys().cloned().collect());
465
466    // Execution tracker — tracking disabled because unused-reference validation was
467    // already done statically by Tracker 1 (pre-validation block above). This tracker
468    // only needs name/value resolution, not usage tracking.
469    let tracker = crate::expressions::TrackedExpressionAttributes::without_tracking(
470        &request.expression_attribute_names,
471        &request.expression_attribute_values,
472    );
473
474    // Wrap the condition check, base write and the GSI/LSI fan-out in a single
475    // transaction so a mid-fan-out failure rolls the whole update back, leaving
476    // no torn index. Unconditional because the atomicity guarantee applies to
477    // every single-item write. The block captures everything from get_item
478    // through the GSI/LSI fan-out and stream record.
479    let (
480        UpdateWorkResult {
481            old_item,
482            item,
483            item_json,
484            size,
485        },
486        gsi_units,
487    ) = helpers::with_write_transaction(storage, async {
488        // Fetch existing item (or create empty one for upsert)
489        let existing_json = storage.get_item(&request.table_name, &pk, &sk).await?;
490        let existing_item: HashMap<String, AttributeValue> = existing_json
491            .as_ref()
492            .and_then(|j| serde_json::from_str(j).ok())
493            .unwrap_or_default();
494
495        // Evaluate ConditionExpression against the original existing item BEFORE
496        // populating key attributes for upsert. Otherwise attribute_exists(PK)
497        // would always pass because the key was pre-populated.
498        if let Some(ref cond_expr) = request.condition_expression {
499            let parsed = crate::expressions::condition::parse(cond_expr)
500                .map_err(DynoxideError::ValidationException)?;
501            let result = crate::expressions::condition::evaluate(&parsed, &existing_item, &tracker)
502                .map_err(DynoxideError::ValidationException)?;
503            if !result {
504                let return_item = if request.return_values_on_condition_check_failure.as_deref()
505                    == Some("ALL_OLD")
506                    && existing_json.is_some()
507                {
508                    Some(existing_item.clone())
509                } else {
510                    None
511                };
512                return Err(DynoxideError::ConditionalCheckFailedException(
513                    "The conditional request failed".to_string(),
514                    return_item,
515                ));
516            }
517        }
518
519        // Build mutable item for the update expression.
520        // If item doesn't exist, populate key attributes for upsert.
521        let mut item = existing_item;
522        if existing_json.is_none() {
523            for (k, v) in &request.key {
524                item.insert(k.clone(), v.clone());
525            }
526        }
527
528        // Save old item for ReturnValues
529        let old_item = item.clone();
530
531        // Apply UpdateExpression
532        if let Some(ref update_expr) = request.update_expression {
533            let parsed = crate::expressions::update::parse(update_expr)
534                .map_err(DynoxideError::ValidationException)?;
535
536            // Validate: cannot modify key attributes with SET
537            // (key validation uses the free function, not tracked)
538            for action in &parsed.set_actions {
539                validate_not_key_attr(
540                    action.path.first(),
541                    &key_schema,
542                    &request.expression_attribute_names,
543                )?;
544            }
545
546            // Validate: cannot REMOVE key attributes
547            for path in &parsed.remove_actions {
548                validate_not_key_attr(
549                    path.first(),
550                    &key_schema,
551                    &request.expression_attribute_names,
552                )?;
553            }
554
555            // Validate: cannot ADD to key attributes
556            for action in &parsed.add_actions {
557                validate_not_key_attr(
558                    action.path.first(),
559                    &key_schema,
560                    &request.expression_attribute_names,
561                )?;
562            }
563
564            // Validate: cannot DELETE from key attributes
565            for action in &parsed.delete_actions {
566                validate_not_key_attr(
567                    action.path.first(),
568                    &key_schema,
569                    &request.expression_attribute_names,
570                )?;
571            }
572
573            crate::expressions::update::apply(&mut item, &parsed, &tracker)
574                .map_err(DynoxideError::ValidationException)?;
575        }
576
577        // Apply legacy AttributeUpdates (if no UpdateExpression was provided)
578        if request.update_expression.is_none() {
579            if let Some(ref updates) = request.attribute_updates {
580                apply_attribute_updates(&mut item, updates, &key_schema)?;
581            }
582        }
583
584        // Note: unused expression attribute validation already done in pre-validation
585        // block (Tracker 1). Not repeated here — runtime evaluation may skip branches
586        // (e.g., if_not_exists short-circuits) which would cause false positives.
587
588        // Validate attribute values after update expression applied. This
589        // checks the post-update item, not the request, so its errors stay
590        // bare: plain `?` converts through the untagged From impl. Do not
591        // route it through into_tagged even though the messages match the
592        // tagged request-time families byte for byte.
593        crate::validation::validate_item_attribute_values(&item)?;
594        crate::validation::normalize_item_sets(&mut item);
595
596        // Reject an index key set to an invalid value, before the fan-out so the
597        // error surfaces. Checks only what this update changed (see the helper).
598        helpers::validate_updated_index_keys(&old_item, &item, &meta)?;
599
600        // Validate updated item size
601        let size = types::item_size(&item);
602        if size > types::MAX_ITEM_SIZE {
603            return Err(DynoxideError::ValidationException(
604                "Item size to update has exceeded the maximum allowed size".to_string(),
605            ));
606        }
607
608        // Serialize and store
609        let item_json = serde_json::to_string(&item)
610            .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
611        let hash_prefix = request
612            .key
613            .get(&key_schema.partition_key)
614            .map(crate::storage::compute_hash_prefix)
615            .unwrap_or_default();
616        storage
617            .put_item_with_hash(
618                &request.table_name,
619                &pk,
620                &sk,
621                &item_json,
622                size,
623                &hash_prefix,
624            )
625            .await?;
626
627        // Maintain GSI tables (inside the transaction)
628        let gsi_units = super::gsi::maintain_gsis_after_write(
629            storage,
630            &request.table_name,
631            &meta,
632            &pk,
633            &sk,
634            &item,
635            &key_schema.partition_key,
636            key_schema.sort_key.as_deref(),
637        )
638        .await?;
639
640        // Maintain LSI tables (inside the transaction)
641        super::lsi::maintain_lsis_after_write(
642            storage,
643            &request.table_name,
644            &meta,
645            &pk,
646            &sk,
647            &item,
648            &key_schema.partition_key,
649            key_schema.sort_key.as_deref(),
650        )
651        .await?;
652
653        // Record stream event (inside the transaction)
654        let old_for_stream = if existing_json.is_some() {
655            Some(&old_item)
656        } else {
657            None
658        };
659        crate::streams::record_stream_event(storage, &meta, old_for_stream, Some(&item)).await?;
660
661        Ok((
662            UpdateWorkResult {
663                old_item,
664                item,
665                item_json,
666                size,
667            },
668            gsi_units,
669        ))
670    })
671    .await?;
672
673    // Handle ReturnValues
674    let return_values = request.return_values.as_deref().unwrap_or("NONE");
675    let attributes = match return_values.to_uppercase().as_str() {
676        "ALL_OLD" => Some(old_item),
677        "ALL_NEW" => Some(item),
678        "UPDATED_OLD" => {
679            if let Some(ref update_expr) = request.update_expression {
680                // Expression-based: extract only the attributes targeted by the expression.
681                let parsed = crate::expressions::update::parse(update_expr)
682                    .map_err(DynoxideError::ValidationException)?;
683                omit_if_empty(extract_updated_attrs(
684                    &old_item,
685                    &parsed,
686                    &request.expression_attribute_names,
687                ))
688            } else {
689                // Legacy AttributeUpdates: extract the named attributes from the old item.
690                legacy_attr_names
691                    .as_ref()
692                    .map(|names| extract_named_attrs(&old_item, names))
693                    .and_then(omit_if_empty)
694            }
695        }
696        "UPDATED_NEW" => {
697            if let Some(ref update_expr) = request.update_expression {
698                // Expression-based: extract only the attributes targeted by the expression.
699                let parsed = crate::expressions::update::parse(update_expr)
700                    .map_err(DynoxideError::ValidationException)?;
701                let new_item: HashMap<String, AttributeValue> = serde_json::from_str(&item_json)
702                    .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
703                omit_if_empty(extract_updated_attrs(
704                    &new_item,
705                    &parsed,
706                    &request.expression_attribute_names,
707                ))
708            } else {
709                // Legacy AttributeUpdates: extract the named attributes from the new item.
710                legacy_attr_names
711                    .as_ref()
712                    .map(|names| {
713                        let new_item: HashMap<String, AttributeValue> =
714                            serde_json::from_str(&item_json).unwrap_or_default();
715                        extract_named_attrs(&new_item, names)
716                    })
717                    .and_then(omit_if_empty)
718            }
719        }
720        _ => None, // "NONE" or default
721    };
722
723    // Build item collection metrics (only for tables with LSIs)
724    let pk_value = request.key.get(&key_schema.partition_key).cloned();
725    let item_collection_metrics = helpers::build_item_collection_metrics(
726        storage,
727        &meta,
728        &request.table_name,
729        &pk,
730        &key_schema.partition_key,
731        pk_value
732            .as_ref()
733            .unwrap_or(&AttributeValue::S(String::new())),
734        &request.return_item_collection_metrics,
735    )
736    .await?;
737
738    let consumed_capacity = types::consumed_capacity_with_indexes(
739        &request.table_name,
740        types::write_capacity_units(size),
741        &gsi_units,
742        &request.return_consumed_capacity,
743    );
744
745    Ok(UpdateItemResponse {
746        attributes,
747        consumed_capacity,
748        item_collection_metrics,
749    })
750}
751
752/// Apply legacy `AttributeUpdates` to the item, mutating it in place.
753///
754/// Each entry maps an attribute name to an action:
755/// - `PUT` (default): set the attribute to the given value
756/// - `ADD`: add a number or union a set
757/// - `DELETE`: remove the attribute, or remove elements from a set
758fn apply_attribute_updates(
759    item: &mut HashMap<String, AttributeValue>,
760    updates: &HashMap<String, AttributeValueUpdate>,
761    key_schema: &helpers::KeySchema,
762) -> Result<()> {
763    for (attr_name, update) in updates {
764        // Cannot modify key attributes
765        if attr_name == &key_schema.partition_key
766            || key_schema
767                .sort_key
768                .as_ref()
769                .is_some_and(|sk| sk == attr_name)
770        {
771            return Err(DynoxideError::ValidationException(format!(
772                "One or more parameter values were invalid: \
773                 Cannot update attribute {attr_name}. This attribute is part of the key"
774            )));
775        }
776
777        let action = update.action.to_uppercase();
778        match action.as_str() {
779            "PUT" => {
780                if let Some(ref value) = update.value {
781                    item.insert(attr_name.clone(), value.clone());
782                }
783            }
784            "ADD" => {
785                if let Some(ref add_val) = update.value {
786                    let path = vec![crate::expressions::PathElement::Attribute(
787                        attr_name.clone(),
788                    )];
789                    crate::expressions::update::apply_add_public(item, &path, add_val)
790                        .map_err(DynoxideError::ValidationException)?;
791                }
792            }
793            "DELETE" => {
794                if let Some(ref del_val) = update.value {
795                    // DELETE with a value: remove elements from a set
796                    let path = vec![crate::expressions::PathElement::Attribute(
797                        attr_name.clone(),
798                    )];
799                    crate::expressions::update::apply_delete_public(item, &path, del_val)
800                        .map_err(DynoxideError::ValidationException)?;
801                } else {
802                    // DELETE without a value: remove the attribute entirely
803                    item.remove(attr_name);
804                }
805            }
806            _ => {
807                return Err(DynoxideError::ValidationException(format!(
808                    "1 validation error detected: Value '{action}' at 'attributeUpdates.{attr_name}.member.action' \
809                     failed to satisfy constraint: Member must satisfy enum value set: [ADD, PUT, DELETE]"
810                )));
811            }
812        }
813    }
814    Ok(())
815}
816
817/// Extract only the attributes that were affected by the update expression,
818/// at full path granularity.
819///
820/// For a nested target like `SET parent.child = :v` this returns only the
821/// changed fragment (`{parent: {M: {child}}}`), not the whole `parent` map,
822/// matching how AWS scopes `UPDATED_NEW` / `UPDATED_OLD`. A path that no longer
823/// resolves in `item` (a removed attribute under `UPDATED_NEW`) contributes
824/// nothing, so a REMOVE-only update yields an empty map.
825fn extract_updated_attrs(
826    item: &HashMap<String, AttributeValue>,
827    expr: &crate::expressions::update::UpdateExpr,
828    attr_names: &Option<HashMap<String, String>>,
829) -> HashMap<String, AttributeValue> {
830    use crate::expressions::{PathElement, resolve_path, resolve_path_elements};
831
832    let no_values: Option<HashMap<String, AttributeValue>> = None;
833    let tracker =
834        crate::expressions::TrackedExpressionAttributes::without_tracking(attr_names, &no_values);
835
836    // Collect every target path across all clauses, in clause order.
837    let mut paths: Vec<&[PathElement]> = Vec::new();
838    paths.extend(expr.set_actions.iter().map(|a| a.path.as_slice()));
839    paths.extend(expr.remove_actions.iter().map(|p| p.as_slice()));
840    paths.extend(expr.add_actions.iter().map(|a| a.path.as_slice()));
841    paths.extend(expr.delete_actions.iter().map(|a| a.path.as_slice()));
842
843    let mut result = HashMap::new();
844    for path in paths {
845        let Ok(resolved) = resolve_path_elements(path, &tracker) else {
846            continue;
847        };
848
849        // `insert_at_path` rebuilds a list from index 0, so it can't represent a
850        // target that dives through a list index (`list[2]`) as a pruned
851        // fragment without mislocating the element and dropping its siblings.
852        // For those, fall back to returning the whole top-level attribute, the
853        // coarse-but-correct shape. Pure attribute paths get the granular
854        // fragment AWS scopes `UPDATED_NEW` / `UPDATED_OLD` to.
855        if resolved.iter().any(|e| matches!(e, PathElement::Index(_))) {
856            if let Some(PathElement::Attribute(top)) = resolved.first() {
857                if let Some(val) = item.get(top) {
858                    result.insert(top.clone(), val.clone());
859                }
860            }
861            continue;
862        }
863
864        if let Some(val) = resolve_path(item, &resolved) {
865            crate::expressions::projection::insert_at_path(&mut result, &resolved, val);
866        }
867    }
868
869    result
870}
871
872/// Collapse an empty projection to `None` so `Attributes` is omitted entirely.
873///
874/// AWS omits `Attributes` from a `UPDATED_NEW` / `UPDATED_OLD` response when
875/// nothing was projected — for example a REMOVE-only update under `UPDATED_NEW`,
876/// where no attribute was set to a new value. Returning `Some({})` instead would
877/// serialise an empty `Attributes` map, which AWS never does.
878fn omit_if_empty(map: HashMap<String, AttributeValue>) -> Option<HashMap<String, AttributeValue>> {
879    if map.is_empty() { None } else { Some(map) }
880}
881
882/// Extract named attributes from an item (used for legacy AttributeUpdates ReturnValues).
883fn extract_named_attrs(
884    item: &HashMap<String, AttributeValue>,
885    attr_names: &[String],
886) -> HashMap<String, AttributeValue> {
887    let mut result = HashMap::new();
888    for name in attr_names {
889        if let Some(val) = item.get(name) {
890            result.insert(name.clone(), val.clone());
891        }
892    }
893    result
894}
895
896/// Validate that a path element does not target a key attribute.
897fn validate_not_key_attr(
898    first_element: Option<&crate::expressions::PathElement>,
899    key_schema: &helpers::KeySchema,
900    expression_attribute_names: &Option<HashMap<String, String>>,
901) -> crate::errors::Result<()> {
902    if let Some(crate::expressions::PathElement::Attribute(name)) = first_element {
903        let resolved_name = if name.starts_with('#') {
904            crate::expressions::resolve_name(name, expression_attribute_names)
905                .map_err(DynoxideError::ValidationException)?
906        } else {
907            name.clone()
908        };
909        if resolved_name == key_schema.partition_key
910            || key_schema
911                .sort_key
912                .as_ref()
913                .is_some_and(|sk| sk == &resolved_name)
914        {
915            return Err(DynoxideError::ValidationException(format!(
916                "One or more parameter values were invalid: Cannot update attribute {resolved_name}. This attribute is part of the key"
917            )));
918        }
919    }
920    Ok(())
921}
922
923#[cfg(test)]
924mod tests {
925    use crate::actions::{create_table, put_item, update_item};
926    use crate::storage::Storage;
927    use crate::storage_backend::StorageBackend;
928
929    #[test]
930    fn update_item_stops_at_first_invalid_enum() {
931        // eu-west-2: UpdateItem reports one error for the first invalid enum
932        // (ReturnValues first), unlike PutItem which aggregates them.
933        let err = serde_json::from_value::<super::UpdateItemRequest>(serde_json::json!({
934            "TableName": "_conformance_valid_table_name",
935            "Key": {"pk": {"S": "test"}},
936            "ReturnValues": "INVALID",
937            "ReturnConsumedCapacity": "INVALID"
938        }))
939        .unwrap_err()
940        .to_string();
941        assert!(err.contains("1 validation error detected"), "got: {err}");
942        assert!(err.contains("enum value set"), "got: {err}");
943        assert!(err.contains("returnValues"), "got: {err}");
944        assert!(!err.contains("returnConsumedCapacity"), "got: {err}");
945    }
946
947    #[test]
948    fn update_item_empty_table_name_reports_only_table_name() {
949        let err = serde_json::from_value::<super::UpdateItemRequest>(serde_json::json!({
950            "TableName": "",
951            "Key": {}
952        }))
953        .unwrap_err()
954        .to_string();
955        assert!(err.contains("1 validation error detected"), "got: {err}");
956        assert!(err.to_lowercase().contains("tablename"), "got: {err}");
957    }
958
959    /// An update and its GSI fan-out succeed or fail as one unit: a mid-fan-out
960    /// failure leaves the item at its pre-update value.
961    #[test]
962    fn update_item_rolls_back_base_write_when_gsi_fan_out_fails() {
963        let storage = Storage::memory().unwrap();
964
965        let create = serde_json::from_value(serde_json::json!({
966            "TableName": "Orders",
967            "KeySchema": [{"AttributeName": "UserId", "KeyType": "HASH"}],
968            "AttributeDefinitions": [
969                {"AttributeName": "UserId", "AttributeType": "S"},
970                {"AttributeName": "Status", "AttributeType": "S"},
971                {"AttributeName": "Priority", "AttributeType": "S"}
972            ],
973            "GlobalSecondaryIndexes": [
974                {"IndexName": "StatusIndex", "KeySchema": [{"AttributeName": "Status", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
975                {"IndexName": "PriorityIndex", "KeySchema": [{"AttributeName": "Priority", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}}
976            ]
977        }))
978        .unwrap();
979        pollster::block_on(create_table::execute(&storage, create)).unwrap();
980
981        let put = serde_json::from_value(serde_json::json!({
982            "TableName": "Orders",
983            "Item": {"UserId": {"S": "u1"}, "Status": {"S": "SHIPPED"}, "Priority": {"S": "HIGH"}, "Note": {"S": "before"}}
984        }))
985        .unwrap();
986        pollster::block_on(put_item::execute(&storage, put)).unwrap();
987
988        // Break the second GSI's fan-out by dropping its physical table.
989        storage.drop_gsi_table("Orders", "PriorityIndex").unwrap();
990
991        let update = serde_json::from_value(serde_json::json!({
992            "TableName": "Orders",
993            "Key": {"UserId": {"S": "u1"}},
994            "UpdateExpression": "SET Note = :n",
995            "ExpressionAttributeValues": {":n": {"S": "after"}}
996        }))
997        .unwrap();
998        let res = pollster::block_on(update_item::execute(&storage, update));
999        assert!(
1000            res.is_err(),
1001            "a mid-fan-out failure must surface as an error"
1002        );
1003
1004        // The base write must roll back: the item is still present at its
1005        // pre-update value.
1006        let rows = pollster::block_on(<Storage as StorageBackend>::scan_items(
1007            &storage,
1008            "Orders",
1009            &Default::default(),
1010        ))
1011        .unwrap();
1012        assert_eq!(
1013            rows.len(),
1014            1,
1015            "the item must still be present after rollback"
1016        );
1017        let raw = &rows[0].2;
1018        assert!(
1019            raw.contains("\"before\"") && !raw.contains("\"after\""),
1020            "update must roll back when fan-out fails, leaving the original value: {raw}"
1021        );
1022    }
1023}