Skip to main content

dynoxide/actions/
update_item.rs

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