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