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    mut request: UpdateItemRequest,
207) -> Result<UpdateItemResponse> {
208    // Validate table name format before checking existence (DynamoDB validates input first)
209    crate::validation::validate_table_name(&request.table_name)?;
210
211    // Validate expression/non-expression parameter conflicts BEFORE Expected conversion
212    {
213        let mut non_expr = Vec::new();
214        let mut expr_params = Vec::new();
215        if request.attribute_updates.is_some() {
216            non_expr.push("AttributeUpdates");
217        }
218        if request.expected.is_some() {
219            non_expr.push("Expected");
220        }
221        if request.update_expression.is_some() {
222            expr_params.push("UpdateExpression");
223        }
224        if request.condition_expression.is_some() {
225            expr_params.push("ConditionExpression");
226        }
227        let no_raw_eav: Option<serde_json::Value> = None;
228        let ctx = helpers::ExpressionParamContext {
229            non_expression_params: non_expr,
230            expression_params: expr_params,
231            all_expression_param_names: vec!["UpdateExpression", "ConditionExpression"],
232            expression_attribute_names: &request.expression_attribute_names,
233            expression_attribute_values: &request.expression_attribute_values,
234            expression_attribute_values_raw: &no_raw_eav,
235        };
236        helpers::validate_expression_params(&ctx)?;
237    }
238
239    // Validate key attribute values (unsupported datatypes, invalid numbers)
240    crate::validation::validate_key_attribute_values(&request.key)?;
241
242    // Validate legacy AttributeUpdates parameters
243    if request.update_expression.is_none() {
244        if let Some(ref updates) = request.attribute_updates {
245            for (attr_name, update) in updates {
246                let action = update.action.to_uppercase();
247                if update.value.is_none() && action != "DELETE" {
248                    return Err(DynoxideError::ValidationException(
249                        "One or more parameter values were invalid: \
250                         Only DELETE action is allowed when no attribute value is specified"
251                            .to_string(),
252                    ));
253                }
254                if action == "DELETE" {
255                    if let Some(ref val) = update.value {
256                        let type_name = match val {
257                            AttributeValue::SS(_)
258                            | AttributeValue::NS(_)
259                            | AttributeValue::BS(_) => None,
260                            _ => Some(val.type_name()),
261                        };
262                        if let Some(tn) = type_name {
263                            return Err(DynoxideError::ValidationException(format!(
264                                "One or more parameter values were invalid: \
265                                 DELETE action with value is not supported for the type {tn}"
266                            )));
267                        }
268                    }
269                }
270                if action == "ADD" {
271                    if let Some(ref val) = update.value {
272                        let allowed = matches!(
273                            val,
274                            AttributeValue::N(_)
275                                | AttributeValue::SS(_)
276                                | AttributeValue::NS(_)
277                                | AttributeValue::BS(_)
278                                | AttributeValue::L(_)
279                        );
280                        if !allowed {
281                            let tn = val.type_name();
282                            return Err(DynoxideError::ValidationException(format!(
283                                "One or more parameter values were invalid: \
284                                 ADD action is not supported for the type {tn}"
285                            )));
286                        }
287                    }
288                }
289                let _ = attr_name; // suppress unused warning
290            }
291        }
292    }
293
294    // Validate legacy Expected parameter
295    if request.condition_expression.is_none() && request.update_expression.is_none() {
296        if let Some(ref expected_val) = request.expected {
297            if let Ok(expected) = serde_json::from_value::<
298                HashMap<String, helpers::ExpectedCondition>,
299            >(expected_val.clone())
300            {
301                helpers::validate_expected_conditions(&expected)?;
302            }
303        }
304    }
305
306    // Validate empty UpdateExpression
307    if let Some(ref ue) = request.update_expression {
308        if ue.is_empty() {
309            return Err(DynoxideError::ValidationException(
310                "Invalid UpdateExpression: The expression can not be empty;".to_string(),
311            ));
312        }
313    }
314
315    // Validate empty ConditionExpression
316    if let Some(ref ce) = request.condition_expression {
317        if ce.is_empty() {
318            return Err(DynoxideError::ValidationException(
319                "Invalid ConditionExpression: The expression can not be empty;".to_string(),
320            ));
321        }
322    }
323
324    // Pre-validate UpdateExpression syntax BEFORE table lookup.
325    // DynamoDB validates expression syntax, reserved keywords, undefined attribute
326    // names/values, overlapping paths, etc. before checking table existence.
327    if let Some(ref ue) = request.update_expression {
328        let parsed =
329            crate::expressions::update::parse(ue).map_err(DynoxideError::ValidationException)?;
330
331        // Track all attribute name/value references statically (without evaluating)
332        let tracker = crate::expressions::TrackedExpressionAttributes::new(
333            &request.expression_attribute_names,
334            &request.expression_attribute_values,
335        );
336        crate::expressions::update::track_references(&parsed, &tracker)
337            .map_err(|e| DynoxideError::ValidationException(wrap_invalid_update_expression(e)))?;
338
339        // Also walk the ConditionExpression to track its attribute usage
340        if let Some(ref ce) = request.condition_expression {
341            if let Ok(cond_parsed) = crate::expressions::condition::parse(ce) {
342                crate::expressions::condition::track_references(&cond_parsed, &tracker)
343                    .map_err(DynoxideError::ValidationException)?;
344            }
345        }
346
347        // Check for unused expression attribute names/values
348        tracker.check_unused()?;
349    }
350
351    // Statically validate ConditionExpression (syntax + BETWEEN bounds, etc.) before table lookup
352    if let Some(ref ce) = request.condition_expression {
353        let parsed = crate::expressions::condition::parse(ce).map_err(|e| {
354            DynoxideError::ValidationException(format!("Invalid ConditionExpression: {e}"))
355        })?;
356        crate::expressions::condition::validate_static(
357            &parsed,
358            &request.expression_attribute_values,
359        )
360        .map_err(DynoxideError::ValidationException)?;
361        crate::expressions::condition::validate_operand_semantics(
362            &parsed,
363            &request.expression_attribute_names,
364            &request.expression_attribute_values,
365        )
366        .map_err(|e| {
367            DynoxideError::ValidationException(format!("Invalid ConditionExpression: {e}"))
368        })?;
369    }
370
371    // Convert legacy Expected parameter to ConditionExpression if no expression is set
372    if request.condition_expression.is_none() {
373        if let Some(ref expected_val) = request.expected {
374            if let Ok(expected) = serde_json::from_value::<
375                HashMap<String, helpers::ExpectedCondition>,
376            >(expected_val.clone())
377            {
378                if !expected.is_empty() {
379                    let (cond_expr, values) = helpers::convert_expected_to_condition(
380                        &expected,
381                        request.conditional_operator.as_deref(),
382                    )?;
383                    if !cond_expr.is_empty() {
384                        let names = helpers::expected_attr_names(&expected);
385                        request.condition_expression = Some(cond_expr);
386                        let expr_values = request
387                            .expression_attribute_values
388                            .get_or_insert_with(HashMap::new);
389                        expr_values.extend(values);
390                        let expr_names = request
391                            .expression_attribute_names
392                            .get_or_insert_with(HashMap::new);
393                        expr_names.extend(names);
394                    }
395                }
396            }
397        }
398    }
399
400    let meta = helpers::require_table_for_item_op(storage, &request.table_name).await?;
401    let key_schema = helpers::parse_key_schema(&meta)?;
402
403    // Validate ReturnValues parameter
404    if let Some(ref rv) = request.return_values {
405        let rv_upper = rv.to_uppercase();
406        if !["NONE", "ALL_OLD", "ALL_NEW", "UPDATED_OLD", "UPDATED_NEW"]
407            .contains(&rv_upper.as_str())
408        {
409            return Err(DynoxideError::ValidationException(format!(
410                "1 validation error detected: Value '{rv}' at 'returnValues' failed to satisfy constraint: \
411                 Member must satisfy enum value set: [ALL_NEW, ALL_OLD, NONE, UPDATED_NEW, UPDATED_OLD]"
412            )));
413        }
414    }
415
416    // Validate key
417    helpers::validate_key_only(&request.key, &key_schema)?;
418
419    // Extract key values
420    // TODO: validation must precede this call -- if reaching this line, caller has already validated keys.
421    let (pk, sk) = helpers::extract_key_strings(&request.key, &key_schema)?;
422
423    // Collect the set of attribute names affected by the legacy AttributeUpdates
424    // parameter, used later for UPDATED_OLD / UPDATED_NEW extraction.
425    let legacy_attr_names: Option<Vec<String>> = request
426        .attribute_updates
427        .as_ref()
428        .map(|updates| updates.keys().cloned().collect());
429
430    // Execution tracker — tracking disabled because unused-reference validation was
431    // already done statically by Tracker 1 (pre-validation block above). This tracker
432    // only needs name/value resolution, not usage tracking.
433    let tracker = crate::expressions::TrackedExpressionAttributes::without_tracking(
434        &request.expression_attribute_names,
435        &request.expression_attribute_values,
436    );
437
438    // Wrap the condition check, base write and the GSI/LSI fan-out in a single
439    // transaction so a mid-fan-out failure rolls the whole update back, leaving
440    // no torn index. Unconditional because the atomicity guarantee applies to
441    // every single-item write. The block captures everything from get_item
442    // through the GSI/LSI fan-out and stream record.
443    let (
444        UpdateWorkResult {
445            old_item,
446            item,
447            item_json,
448            size,
449        },
450        gsi_units,
451    ) = helpers::with_write_transaction(storage, async {
452        // Fetch existing item (or create empty one for upsert)
453        let existing_json = storage.get_item(&request.table_name, &pk, &sk).await?;
454        let existing_item: HashMap<String, AttributeValue> = existing_json
455            .as_ref()
456            .and_then(|j| serde_json::from_str(j).ok())
457            .unwrap_or_default();
458
459        // Evaluate ConditionExpression against the original existing item BEFORE
460        // populating key attributes for upsert. Otherwise attribute_exists(PK)
461        // would always pass because the key was pre-populated.
462        if let Some(ref cond_expr) = request.condition_expression {
463            let parsed = crate::expressions::condition::parse(cond_expr)
464                .map_err(DynoxideError::ValidationException)?;
465            let result = crate::expressions::condition::evaluate(&parsed, &existing_item, &tracker)
466                .map_err(DynoxideError::ValidationException)?;
467            if !result {
468                let return_item = if request.return_values_on_condition_check_failure.as_deref()
469                    == Some("ALL_OLD")
470                    && existing_json.is_some()
471                {
472                    Some(existing_item.clone())
473                } else {
474                    None
475                };
476                return Err(DynoxideError::ConditionalCheckFailedException(
477                    "The conditional request failed".to_string(),
478                    return_item,
479                ));
480            }
481        }
482
483        // Build mutable item for the update expression.
484        // If item doesn't exist, populate key attributes for upsert.
485        let mut item = existing_item;
486        if existing_json.is_none() {
487            for (k, v) in &request.key {
488                item.insert(k.clone(), v.clone());
489            }
490        }
491
492        // Save old item for ReturnValues
493        let old_item = item.clone();
494
495        // Apply UpdateExpression
496        if let Some(ref update_expr) = request.update_expression {
497            let parsed = crate::expressions::update::parse(update_expr)
498                .map_err(DynoxideError::ValidationException)?;
499
500            // Validate: cannot modify key attributes with SET
501            // (key validation uses the free function, not tracked)
502            for action in &parsed.set_actions {
503                validate_not_key_attr(
504                    action.path.first(),
505                    &key_schema,
506                    &request.expression_attribute_names,
507                )?;
508            }
509
510            // Validate: cannot REMOVE key attributes
511            for path in &parsed.remove_actions {
512                validate_not_key_attr(
513                    path.first(),
514                    &key_schema,
515                    &request.expression_attribute_names,
516                )?;
517            }
518
519            // Validate: cannot ADD to key attributes
520            for action in &parsed.add_actions {
521                validate_not_key_attr(
522                    action.path.first(),
523                    &key_schema,
524                    &request.expression_attribute_names,
525                )?;
526            }
527
528            // Validate: cannot DELETE from key attributes
529            for action in &parsed.delete_actions {
530                validate_not_key_attr(
531                    action.path.first(),
532                    &key_schema,
533                    &request.expression_attribute_names,
534                )?;
535            }
536
537            crate::expressions::update::apply(&mut item, &parsed, &tracker)
538                .map_err(DynoxideError::ValidationException)?;
539        }
540
541        // Apply legacy AttributeUpdates (if no UpdateExpression was provided)
542        if request.update_expression.is_none() {
543            if let Some(ref updates) = request.attribute_updates {
544                apply_attribute_updates(&mut item, updates, &key_schema)?;
545            }
546        }
547
548        // Note: unused expression attribute validation already done in pre-validation
549        // block (Tracker 1). Not repeated here — runtime evaluation may skip branches
550        // (e.g., if_not_exists short-circuits) which would cause false positives.
551
552        // Validate attribute values after update expression applied
553        crate::validation::validate_item_attribute_values(&item)?;
554        crate::validation::normalize_item_sets(&mut item);
555
556        // Reject an index key set to an invalid value, before the fan-out so the
557        // error surfaces. Checks only what this update changed (see the helper).
558        helpers::validate_updated_index_keys(&old_item, &item, &meta)?;
559
560        // Validate updated item size
561        let size = types::item_size(&item);
562        if size > types::MAX_ITEM_SIZE {
563            return Err(DynoxideError::ValidationException(
564                "Item size to update has exceeded the maximum allowed size".to_string(),
565            ));
566        }
567
568        // Serialize and store
569        let item_json = serde_json::to_string(&item)
570            .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
571        let hash_prefix = request
572            .key
573            .get(&key_schema.partition_key)
574            .map(crate::storage::compute_hash_prefix)
575            .unwrap_or_default();
576        storage
577            .put_item_with_hash(
578                &request.table_name,
579                &pk,
580                &sk,
581                &item_json,
582                size,
583                &hash_prefix,
584            )
585            .await?;
586
587        // Maintain GSI tables (inside the transaction)
588        let gsi_units = super::gsi::maintain_gsis_after_write(
589            storage,
590            &request.table_name,
591            &meta,
592            &pk,
593            &sk,
594            &item,
595            &key_schema.partition_key,
596            key_schema.sort_key.as_deref(),
597        )
598        .await?;
599
600        // Maintain LSI tables (inside the transaction)
601        super::lsi::maintain_lsis_after_write(
602            storage,
603            &request.table_name,
604            &meta,
605            &pk,
606            &sk,
607            &item,
608            &key_schema.partition_key,
609            key_schema.sort_key.as_deref(),
610        )
611        .await?;
612
613        // Record stream event (inside the transaction)
614        let old_for_stream = if existing_json.is_some() {
615            Some(&old_item)
616        } else {
617            None
618        };
619        crate::streams::record_stream_event(storage, &meta, old_for_stream, Some(&item)).await?;
620
621        Ok((
622            UpdateWorkResult {
623                old_item,
624                item,
625                item_json,
626                size,
627            },
628            gsi_units,
629        ))
630    })
631    .await?;
632
633    // Handle ReturnValues
634    let return_values = request.return_values.as_deref().unwrap_or("NONE");
635    let attributes = match return_values.to_uppercase().as_str() {
636        "ALL_OLD" => Some(old_item),
637        "ALL_NEW" => Some(item),
638        "UPDATED_OLD" => {
639            if let Some(ref update_expr) = request.update_expression {
640                // Expression-based: extract only the attributes targeted by the expression.
641                let parsed = crate::expressions::update::parse(update_expr)
642                    .map_err(DynoxideError::ValidationException)?;
643                omit_if_empty(extract_updated_attrs(
644                    &old_item,
645                    &parsed,
646                    &request.expression_attribute_names,
647                ))
648            } else {
649                // Legacy AttributeUpdates: extract the named attributes from the old item.
650                legacy_attr_names
651                    .as_ref()
652                    .map(|names| extract_named_attrs(&old_item, names))
653                    .and_then(omit_if_empty)
654            }
655        }
656        "UPDATED_NEW" => {
657            if let Some(ref update_expr) = request.update_expression {
658                // Expression-based: extract only the attributes targeted by the expression.
659                let parsed = crate::expressions::update::parse(update_expr)
660                    .map_err(DynoxideError::ValidationException)?;
661                let new_item: HashMap<String, AttributeValue> = serde_json::from_str(&item_json)
662                    .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
663                omit_if_empty(extract_updated_attrs(
664                    &new_item,
665                    &parsed,
666                    &request.expression_attribute_names,
667                ))
668            } else {
669                // Legacy AttributeUpdates: extract the named attributes from the new item.
670                legacy_attr_names
671                    .as_ref()
672                    .map(|names| {
673                        let new_item: HashMap<String, AttributeValue> =
674                            serde_json::from_str(&item_json).unwrap_or_default();
675                        extract_named_attrs(&new_item, names)
676                    })
677                    .and_then(omit_if_empty)
678            }
679        }
680        _ => None, // "NONE" or default
681    };
682
683    // Build item collection metrics (only for tables with LSIs)
684    let pk_value = request.key.get(&key_schema.partition_key).cloned();
685    let item_collection_metrics = helpers::build_item_collection_metrics(
686        storage,
687        &meta,
688        &request.table_name,
689        &pk,
690        &key_schema.partition_key,
691        pk_value
692            .as_ref()
693            .unwrap_or(&AttributeValue::S(String::new())),
694        &request.return_item_collection_metrics,
695    )
696    .await?;
697
698    let consumed_capacity = types::consumed_capacity_with_indexes(
699        &request.table_name,
700        types::write_capacity_units(size),
701        &gsi_units,
702        &request.return_consumed_capacity,
703    );
704
705    Ok(UpdateItemResponse {
706        attributes,
707        consumed_capacity,
708        item_collection_metrics,
709    })
710}
711
712/// Apply legacy `AttributeUpdates` to the item, mutating it in place.
713///
714/// Each entry maps an attribute name to an action:
715/// - `PUT` (default): set the attribute to the given value
716/// - `ADD`: add a number or union a set
717/// - `DELETE`: remove the attribute, or remove elements from a set
718fn apply_attribute_updates(
719    item: &mut HashMap<String, AttributeValue>,
720    updates: &HashMap<String, AttributeValueUpdate>,
721    key_schema: &helpers::KeySchema,
722) -> Result<()> {
723    for (attr_name, update) in updates {
724        // Cannot modify key attributes
725        if attr_name == &key_schema.partition_key
726            || key_schema
727                .sort_key
728                .as_ref()
729                .is_some_and(|sk| sk == attr_name)
730        {
731            return Err(DynoxideError::ValidationException(format!(
732                "One or more parameter values were invalid: \
733                 Cannot update attribute {attr_name}. This attribute is part of the key"
734            )));
735        }
736
737        let action = update.action.to_uppercase();
738        match action.as_str() {
739            "PUT" => {
740                if let Some(ref value) = update.value {
741                    item.insert(attr_name.clone(), value.clone());
742                }
743            }
744            "ADD" => {
745                if let Some(ref add_val) = update.value {
746                    let path = vec![crate::expressions::PathElement::Attribute(
747                        attr_name.clone(),
748                    )];
749                    crate::expressions::update::apply_add_public(item, &path, add_val)
750                        .map_err(DynoxideError::ValidationException)?;
751                }
752            }
753            "DELETE" => {
754                if let Some(ref del_val) = update.value {
755                    // DELETE with a value: remove elements from a set
756                    let path = vec![crate::expressions::PathElement::Attribute(
757                        attr_name.clone(),
758                    )];
759                    crate::expressions::update::apply_delete_public(item, &path, del_val)
760                        .map_err(DynoxideError::ValidationException)?;
761                } else {
762                    // DELETE without a value: remove the attribute entirely
763                    item.remove(attr_name);
764                }
765            }
766            _ => {
767                return Err(DynoxideError::ValidationException(format!(
768                    "1 validation error detected: Value '{action}' at 'attributeUpdates.{attr_name}.member.action' \
769                     failed to satisfy constraint: Member must satisfy enum value set: [ADD, PUT, DELETE]"
770                )));
771            }
772        }
773    }
774    Ok(())
775}
776
777/// Extract only the attributes that were affected by the update expression,
778/// at full path granularity.
779///
780/// For a nested target like `SET parent.child = :v` this returns only the
781/// changed fragment (`{parent: {M: {child}}}`), not the whole `parent` map,
782/// matching how AWS scopes `UPDATED_NEW` / `UPDATED_OLD`. A path that no longer
783/// resolves in `item` (a removed attribute under `UPDATED_NEW`) contributes
784/// nothing, so a REMOVE-only update yields an empty map.
785fn extract_updated_attrs(
786    item: &HashMap<String, AttributeValue>,
787    expr: &crate::expressions::update::UpdateExpr,
788    attr_names: &Option<HashMap<String, String>>,
789) -> HashMap<String, AttributeValue> {
790    use crate::expressions::{PathElement, resolve_path, resolve_path_elements};
791
792    let no_values: Option<HashMap<String, AttributeValue>> = None;
793    let tracker =
794        crate::expressions::TrackedExpressionAttributes::without_tracking(attr_names, &no_values);
795
796    // Collect every target path across all clauses, in clause order.
797    let mut paths: Vec<&[PathElement]> = Vec::new();
798    paths.extend(expr.set_actions.iter().map(|a| a.path.as_slice()));
799    paths.extend(expr.remove_actions.iter().map(|p| p.as_slice()));
800    paths.extend(expr.add_actions.iter().map(|a| a.path.as_slice()));
801    paths.extend(expr.delete_actions.iter().map(|a| a.path.as_slice()));
802
803    let mut result = HashMap::new();
804    for path in paths {
805        let Ok(resolved) = resolve_path_elements(path, &tracker) else {
806            continue;
807        };
808
809        // `insert_at_path` rebuilds a list from index 0, so it can't represent a
810        // target that dives through a list index (`list[2]`) as a pruned
811        // fragment without mislocating the element and dropping its siblings.
812        // For those, fall back to returning the whole top-level attribute, the
813        // coarse-but-correct shape. Pure attribute paths get the granular
814        // fragment AWS scopes `UPDATED_NEW` / `UPDATED_OLD` to.
815        if resolved.iter().any(|e| matches!(e, PathElement::Index(_))) {
816            if let Some(PathElement::Attribute(top)) = resolved.first() {
817                if let Some(val) = item.get(top) {
818                    result.insert(top.clone(), val.clone());
819                }
820            }
821            continue;
822        }
823
824        if let Some(val) = resolve_path(item, &resolved) {
825            crate::expressions::projection::insert_at_path(&mut result, &resolved, val);
826        }
827    }
828
829    result
830}
831
832/// Collapse an empty projection to `None` so `Attributes` is omitted entirely.
833///
834/// AWS omits `Attributes` from a `UPDATED_NEW` / `UPDATED_OLD` response when
835/// nothing was projected — for example a REMOVE-only update under `UPDATED_NEW`,
836/// where no attribute was set to a new value. Returning `Some({})` instead would
837/// serialise an empty `Attributes` map, which AWS never does.
838fn omit_if_empty(map: HashMap<String, AttributeValue>) -> Option<HashMap<String, AttributeValue>> {
839    if map.is_empty() { None } else { Some(map) }
840}
841
842/// Extract named attributes from an item (used for legacy AttributeUpdates ReturnValues).
843fn extract_named_attrs(
844    item: &HashMap<String, AttributeValue>,
845    attr_names: &[String],
846) -> HashMap<String, AttributeValue> {
847    let mut result = HashMap::new();
848    for name in attr_names {
849        if let Some(val) = item.get(name) {
850            result.insert(name.clone(), val.clone());
851        }
852    }
853    result
854}
855
856/// Validate that a path element does not target a key attribute.
857fn validate_not_key_attr(
858    first_element: Option<&crate::expressions::PathElement>,
859    key_schema: &helpers::KeySchema,
860    expression_attribute_names: &Option<HashMap<String, String>>,
861) -> crate::errors::Result<()> {
862    if let Some(crate::expressions::PathElement::Attribute(name)) = first_element {
863        let resolved_name = if name.starts_with('#') {
864            crate::expressions::resolve_name(name, expression_attribute_names)
865                .map_err(DynoxideError::ValidationException)?
866        } else {
867            name.clone()
868        };
869        if resolved_name == key_schema.partition_key
870            || key_schema
871                .sort_key
872                .as_ref()
873                .is_some_and(|sk| sk == &resolved_name)
874        {
875            return Err(DynoxideError::ValidationException(format!(
876                "One or more parameter values were invalid: Cannot update attribute {resolved_name}. This attribute is part of the key"
877            )));
878        }
879    }
880    Ok(())
881}
882
883#[cfg(test)]
884mod tests {
885    use crate::actions::{create_table, put_item, update_item};
886    use crate::storage::Storage;
887    use crate::storage_backend::StorageBackend;
888
889    #[test]
890    fn update_item_stops_at_first_invalid_enum() {
891        // eu-west-2: UpdateItem reports one error for the first invalid enum
892        // (ReturnValues first), unlike PutItem which aggregates them.
893        let err = serde_json::from_value::<super::UpdateItemRequest>(serde_json::json!({
894            "TableName": "_conformance_valid_table_name",
895            "Key": {"pk": {"S": "test"}},
896            "ReturnValues": "INVALID",
897            "ReturnConsumedCapacity": "INVALID"
898        }))
899        .unwrap_err()
900        .to_string();
901        assert!(err.contains("1 validation error detected"), "got: {err}");
902        assert!(err.contains("enum value set"), "got: {err}");
903        assert!(err.contains("returnValues"), "got: {err}");
904        assert!(!err.contains("returnConsumedCapacity"), "got: {err}");
905    }
906
907    #[test]
908    fn update_item_empty_table_name_reports_only_table_name() {
909        let err = serde_json::from_value::<super::UpdateItemRequest>(serde_json::json!({
910            "TableName": "",
911            "Key": {}
912        }))
913        .unwrap_err()
914        .to_string();
915        assert!(err.contains("1 validation error detected"), "got: {err}");
916        assert!(err.to_lowercase().contains("tablename"), "got: {err}");
917    }
918
919    /// An update and its GSI fan-out succeed or fail as one unit: a mid-fan-out
920    /// failure leaves the item at its pre-update value.
921    #[test]
922    fn update_item_rolls_back_base_write_when_gsi_fan_out_fails() {
923        let storage = Storage::memory().unwrap();
924
925        let create = serde_json::from_value(serde_json::json!({
926            "TableName": "Orders",
927            "KeySchema": [{"AttributeName": "UserId", "KeyType": "HASH"}],
928            "AttributeDefinitions": [
929                {"AttributeName": "UserId", "AttributeType": "S"},
930                {"AttributeName": "Status", "AttributeType": "S"},
931                {"AttributeName": "Priority", "AttributeType": "S"}
932            ],
933            "GlobalSecondaryIndexes": [
934                {"IndexName": "StatusIndex", "KeySchema": [{"AttributeName": "Status", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
935                {"IndexName": "PriorityIndex", "KeySchema": [{"AttributeName": "Priority", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}}
936            ]
937        }))
938        .unwrap();
939        pollster::block_on(create_table::execute(&storage, create)).unwrap();
940
941        let put = serde_json::from_value(serde_json::json!({
942            "TableName": "Orders",
943            "Item": {"UserId": {"S": "u1"}, "Status": {"S": "SHIPPED"}, "Priority": {"S": "HIGH"}, "Note": {"S": "before"}}
944        }))
945        .unwrap();
946        pollster::block_on(put_item::execute(&storage, put)).unwrap();
947
948        // Break the second GSI's fan-out by dropping its physical table.
949        storage.drop_gsi_table("Orders", "PriorityIndex").unwrap();
950
951        let update = serde_json::from_value(serde_json::json!({
952            "TableName": "Orders",
953            "Key": {"UserId": {"S": "u1"}},
954            "UpdateExpression": "SET Note = :n",
955            "ExpressionAttributeValues": {":n": {"S": "after"}}
956        }))
957        .unwrap();
958        let res = pollster::block_on(update_item::execute(&storage, update));
959        assert!(
960            res.is_err(),
961            "a mid-fan-out failure must surface as an error"
962        );
963
964        // The base write must roll back: the item is still present at its
965        // pre-update value.
966        let rows = pollster::block_on(<Storage as StorageBackend>::scan_items(
967            &storage,
968            "Orders",
969            &Default::default(),
970        ))
971        .unwrap();
972        assert_eq!(
973            rows.len(),
974            1,
975            "the item must still be present after rollback"
976        );
977        let raw = &rows[0].2;
978        assert!(
979            raw.contains("\"before\"") && !raw.contains("\"after\""),
980            "update must roll back when fan-out fails, leaving the original value: {raw}"
981        );
982    }
983}