Skip to main content

dynoxide/actions/
transact_write_items.rs

1use crate::actions::helpers;
2use crate::errors::{CancellationReason, DynoxideError, Result};
3use crate::storage_backend::StorageBackend;
4use crate::types::{self, AttributeValue, Item};
5use serde::{Deserialize, Serialize};
6use std::collections::{HashMap, HashSet};
7
8#[derive(Debug, Clone, Default, Deserialize, Serialize)]
9pub struct TransactWriteItemsRequest {
10    #[serde(rename = "TransactItems")]
11    pub transact_items: Vec<TransactWriteItem>,
12    #[serde(rename = "ClientRequestToken", default)]
13    pub client_request_token: Option<String>,
14    #[serde(rename = "ReturnConsumedCapacity", default)]
15    pub return_consumed_capacity: Option<String>,
16    #[serde(rename = "ReturnItemCollectionMetrics", default)]
17    pub return_item_collection_metrics: Option<String>,
18}
19
20#[derive(Debug, Clone, Default, Deserialize, Serialize)]
21pub struct TransactWriteItem {
22    #[serde(rename = "Put", default)]
23    pub put: Option<TransactPut>,
24    #[serde(rename = "Update", default)]
25    pub update: Option<TransactUpdate>,
26    #[serde(rename = "Delete", default)]
27    pub delete: Option<TransactDelete>,
28    #[serde(rename = "ConditionCheck", default)]
29    pub condition_check: Option<TransactConditionCheck>,
30}
31
32#[derive(Debug, Clone, Default, Deserialize, Serialize)]
33pub struct TransactPut {
34    #[serde(rename = "TableName")]
35    pub table_name: String,
36    #[serde(rename = "Item")]
37    pub item: Item,
38    #[serde(rename = "ConditionExpression", default)]
39    pub condition_expression: Option<String>,
40    #[serde(rename = "ExpressionAttributeNames", default)]
41    pub expression_attribute_names: Option<HashMap<String, String>>,
42    #[serde(rename = "ExpressionAttributeValues", default)]
43    pub expression_attribute_values: Option<HashMap<String, AttributeValue>>,
44    #[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
45    pub return_values_on_condition_check_failure: Option<String>,
46}
47
48#[derive(Debug, Clone, Default, Deserialize, Serialize)]
49pub struct TransactUpdate {
50    #[serde(rename = "TableName")]
51    pub table_name: String,
52    #[serde(rename = "Key")]
53    pub key: HashMap<String, AttributeValue>,
54    #[serde(rename = "UpdateExpression")]
55    pub update_expression: String,
56    #[serde(rename = "ConditionExpression", default)]
57    pub condition_expression: Option<String>,
58    #[serde(rename = "ExpressionAttributeNames", default)]
59    pub expression_attribute_names: Option<HashMap<String, String>>,
60    #[serde(rename = "ExpressionAttributeValues", default)]
61    pub expression_attribute_values: Option<HashMap<String, AttributeValue>>,
62    #[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
63    pub return_values_on_condition_check_failure: Option<String>,
64}
65
66#[derive(Debug, Clone, Default, Deserialize, Serialize)]
67pub struct TransactDelete {
68    #[serde(rename = "TableName")]
69    pub table_name: String,
70    #[serde(rename = "Key")]
71    pub key: HashMap<String, AttributeValue>,
72    #[serde(rename = "ConditionExpression", default)]
73    pub condition_expression: Option<String>,
74    #[serde(rename = "ExpressionAttributeNames", default)]
75    pub expression_attribute_names: Option<HashMap<String, String>>,
76    #[serde(rename = "ExpressionAttributeValues", default)]
77    pub expression_attribute_values: Option<HashMap<String, AttributeValue>>,
78    #[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
79    pub return_values_on_condition_check_failure: Option<String>,
80}
81
82#[derive(Debug, Clone, Default, Deserialize, Serialize)]
83pub struct TransactConditionCheck {
84    #[serde(rename = "TableName")]
85    pub table_name: String,
86    #[serde(rename = "Key")]
87    pub key: HashMap<String, AttributeValue>,
88    #[serde(rename = "ConditionExpression")]
89    pub condition_expression: String,
90    #[serde(rename = "ExpressionAttributeNames", default)]
91    pub expression_attribute_names: Option<HashMap<String, String>>,
92    #[serde(rename = "ExpressionAttributeValues", default)]
93    pub expression_attribute_values: Option<HashMap<String, AttributeValue>>,
94    #[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
95    pub return_values_on_condition_check_failure: Option<String>,
96}
97
98#[derive(Debug, Clone, Default, Serialize)]
99pub struct TransactWriteItemsResponse {
100    #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
101    pub consumed_capacity: Option<Vec<crate::types::ConsumedCapacity>>,
102    /// Item collection metrics per table. Currently always `None` — full metrics
103    /// computation for transactional writes is deferred to a future release.
104    #[serde(
105        rename = "ItemCollectionMetrics",
106        skip_serializing_if = "Option::is_none"
107    )]
108    pub item_collection_metrics: Option<HashMap<String, Vec<crate::types::ItemCollectionMetrics>>>,
109}
110
111pub async fn execute<S: StorageBackend>(
112    storage: &S,
113    request: TransactWriteItemsRequest,
114) -> Result<TransactWriteItemsResponse> {
115    let items = &request.transact_items;
116
117    // Validate: at least 1 action
118    if items.is_empty() {
119        return Err(DynoxideError::ValidationException(
120            "1 validation error detected: Value '[]' at 'transactItems' failed to satisfy constraint: Member must have length greater than or equal to 1".to_string(),
121        ));
122    }
123
124    // Validate: up to 100 actions.
125    // AWS surfaces this as the standard "1 validation error detected" envelope
126    // around `Value '[<dump>]' at 'transactItems'`. The conformance suite
127    // anchors a regex on the envelope and constraint phrase but leaves the
128    // dump body unconstrained.
129    if items.len() > 100 {
130        let dump = format!("{items:?}");
131        return Err(DynoxideError::ValidationException(format!(
132            "1 validation error detected: Value '[{dump}]' at 'transactItems' failed to satisfy constraint: Member must have length less than or equal to 100"
133        )));
134    }
135
136    // Validate: no duplicate item targets. A key that can't be stringified (non-scalar
137    // or missing) is skipped here and reported by the in-loop validation instead (#95).
138    let mut seen_targets = HashSet::new();
139    for item in items {
140        if let Some(target) = get_item_target(storage, item).await? {
141            if !seen_targets.insert(target) {
142                return Err(DynoxideError::ValidationException(
143                    "Transaction request cannot include multiple operations on one item"
144                        .to_string(),
145                ));
146            }
147        }
148    }
149
150    // Validate: aggregate item size must not exceed 4MB
151    let total_size: usize = items.iter().map(|i| get_action_table_and_size(i).1).sum();
152    if total_size > 4 * 1024 * 1024 {
153        return Err(DynoxideError::ValidationException(
154            "Collection size of items exceeded, which can also be caused by the aggregate size of the items in the transaction exceeding the 4MB limit".to_string(),
155        ));
156    }
157
158    // All actions run inside one SQLite transaction (all-or-nothing).
159    helpers::with_write_transaction(storage, execute_within_transaction(storage, items)).await?;
160
161    // Build consumed capacity per table
162    let consumed_capacity = crate::types::build_transactional_capacity(
163        &transact_write_table_units(items),
164        &request.return_consumed_capacity,
165        crate::types::transactional_write_capacity,
166    );
167    Ok(TransactWriteItemsResponse {
168        consumed_capacity,
169        item_collection_metrics: None,
170    })
171}
172
173/// Per-table transactional write units for a set of actions. AWS charges 2 WCU
174/// per item for a transactional write: each item is rounded up to whole write
175/// units first, then doubled by the transactional factor, then summed per table
176/// (aggregating sizes before rounding would undercharge items straddling a 1KB
177/// boundary). Backs `execute`'s first-call write capacity.
178pub(crate) fn transact_write_table_units(items: &[TransactWriteItem]) -> HashMap<String, f64> {
179    let mut table_units: HashMap<String, f64> = HashMap::new();
180    for item in items {
181        let (table, size) = get_action_table_and_size(item);
182        *table_units.entry(table).or_default() +=
183            crate::types::TRANSACTIONAL_CAPACITY_FACTOR * crate::types::write_capacity_units(size);
184    }
185    table_units
186}
187
188/// Per-table transactional read units for a set of actions. Real AWS recomputes
189/// a same-token replay as a transactional read against each item's size: 2 RCU
190/// per item, rounded at 4KB read granularity before the transactional factor,
191/// summed per table. This differs from the write magnitude above 1KB, where
192/// writes round at 1KB and reads at 4KB. Backs `replay_response`.
193fn transact_read_table_units(items: &[TransactWriteItem]) -> HashMap<String, f64> {
194    let mut table_units: HashMap<String, f64> = HashMap::new();
195    for item in items {
196        let (table, size) = get_action_table_and_size(item);
197        *table_units.entry(table).or_default() +=
198            crate::types::TRANSACTIONAL_CAPACITY_FACTOR * crate::types::read_capacity_units(size);
199    }
200    table_units
201}
202
203/// Build the response for a same-token idempotent replay. The items are
204/// identical to the first call (the idempotency hash matched), so capacity is
205/// recomputed as a transactional READ against the item sizes rather than
206/// re-serving the first call's write numbers, honouring the replay request's
207/// own `ReturnConsumedCapacity` mode (the original call's mode does not carry
208/// over). The read cost is computed at 4KB read granularity, which diverges
209/// from the first-call write magnitude above 1KB. `cached_metrics` carries the
210/// item collection metrics from the cached first-call response.
211pub(crate) fn replay_response(
212    items: &[TransactWriteItem],
213    mode: &Option<String>,
214    cached_metrics: Option<HashMap<String, Vec<crate::types::ItemCollectionMetrics>>>,
215) -> TransactWriteItemsResponse {
216    TransactWriteItemsResponse {
217        consumed_capacity: crate::types::build_transactional_capacity(
218            &transact_read_table_units(items),
219            mode,
220            crate::types::transactional_read_capacity,
221        ),
222        item_collection_metrics: cached_metrics,
223    }
224}
225
226async fn execute_within_transaction<S: StorageBackend>(
227    storage: &S,
228    items: &[TransactWriteItem],
229) -> Result<()> {
230    let mut cancellation_reasons: Vec<CancellationReason> = Vec::with_capacity(items.len());
231    let mut has_failure = false;
232
233    for item in items {
234        let reason = execute_single_action(storage, item).await;
235        match reason {
236            Ok(()) => {
237                cancellation_reasons.push(CancellationReason {
238                    code: "None".to_string(),
239                    message: None,
240                    item: None,
241                });
242            }
243            Err(e) => {
244                // An empty-value key (empty string or empty binary) surfaces top-level:
245                // returning here rolls the transaction back. Other errors become
246                // cancellation reasons below (#95).
247                if matches!(e, DynoxideError::KeyEmptyValueValidation(_)) {
248                    return Err(e);
249                }
250                has_failure = true;
251                let message = Some(e.to_string());
252                let (code, item) = match e {
253                    DynoxideError::ConditionalCheckFailedException(_, item) => {
254                        ("ConditionalCheckFailed".to_string(), item)
255                    }
256                    DynoxideError::ValidationException(_) => ("ValidationError".to_string(), None),
257                    _ => ("InternalError".to_string(), None),
258                };
259                cancellation_reasons.push(CancellationReason {
260                    code,
261                    message,
262                    item,
263                });
264            }
265        }
266    }
267
268    if has_failure {
269        let codes: Vec<&str> = cancellation_reasons
270            .iter()
271            .map(|r| r.code.as_str())
272            .collect();
273        let message = format!(
274            "Transaction cancelled, please refer cancellation reasons for specific reasons [{}]",
275            codes.join(", ")
276        );
277        return Err(DynoxideError::TransactionCanceledException(
278            message,
279            cancellation_reasons,
280        ));
281    }
282
283    Ok(())
284}
285
286async fn execute_single_action<S: StorageBackend>(
287    storage: &S,
288    item: &TransactWriteItem,
289) -> Result<()> {
290    if let Some(ref put) = item.put {
291        execute_put(storage, put).await
292    } else if let Some(ref update) = item.update {
293        execute_update(storage, update).await
294    } else if let Some(ref delete) = item.delete {
295        execute_delete(storage, delete).await
296    } else if let Some(ref check) = item.condition_check {
297        execute_condition_check(storage, check).await
298    } else {
299        Err(DynoxideError::ValidationException(
300            "TransactItem must contain exactly one of Put, Update, Delete, or ConditionCheck"
301                .to_string(),
302        ))
303    }
304}
305
306/// Reject any ExpressionAttributeValue that nests deeper than DynamoDB allows.
307/// TransactWriteItems does not route through the shared expression-param helper, so
308/// the per-value nesting check is applied here for each sub-action's values.
309fn validate_eav_nesting(values: &Option<HashMap<String, AttributeValue>>) -> Result<()> {
310    if let Some(map) = values {
311        for value in map.values() {
312            crate::validation::validate_nesting_depth(value)?;
313        }
314    }
315    Ok(())
316}
317
318async fn execute_put<S: StorageBackend>(storage: &S, put: &TransactPut) -> Result<()> {
319    crate::validation::validate_table_name(&put.table_name)?;
320    let meta = helpers::require_table_for_item_op(storage, &put.table_name).await?;
321    let key_schema = helpers::parse_key_schema(&meta)?;
322
323    helpers::validate_item_keys(&put.item, &key_schema, &meta)?;
324    crate::validation::validate_item_attribute_values(&put.item)?;
325
326    // Deduplicate sets - need a mutable copy since put is borrowed immutably
327    let mut item = put.item.clone();
328    crate::validation::normalize_item_sets(&mut item);
329
330    let size = types::item_size(&item);
331    if size > types::MAX_ITEM_SIZE {
332        return Err(DynoxideError::ValidationException(
333            "Item size has exceeded the maximum allowed size".to_string(),
334        ));
335    }
336
337    // TODO: validation must precede this call -- if reaching this line, caller has already validated keys.
338    let (pk, sk) = helpers::extract_key_strings(&item, &key_schema)?;
339
340    validate_eav_nesting(&put.expression_attribute_values)?;
341
342    let tracker = crate::expressions::TrackedExpressionAttributes::new(
343        &put.expression_attribute_names,
344        &put.expression_attribute_values,
345    );
346
347    // Pre-register references statically before runtime evaluation
348    if let Some(ref cond_expr) = put.condition_expression {
349        if let Ok(parsed) = crate::expressions::condition::parse(cond_expr) {
350            tracker.track_condition_expr(&parsed);
351        }
352    }
353
354    // Evaluate condition if present
355    if let Some(ref cond_expr) = put.condition_expression {
356        let existing_json = storage.get_item(&put.table_name, &pk, &sk).await?;
357        let existing_item: Item = existing_json
358            .as_ref()
359            .and_then(|j| serde_json::from_str(j).ok())
360            .unwrap_or_default();
361
362        let return_item = if put.return_values_on_condition_check_failure.as_deref()
363            == Some("ALL_OLD")
364            && !existing_item.is_empty()
365        {
366            Some(existing_item.clone())
367        } else {
368            None
369        };
370        check_condition_tracked(cond_expr, &existing_item, &tracker, return_item)?;
371    }
372
373    tracker.check_unused()?;
374
375    let item_json = serde_json::to_string(&item)
376        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
377    let hash_prefix = item
378        .get(&key_schema.partition_key)
379        .map(crate::storage::compute_hash_prefix)
380        .unwrap_or_default();
381    let old_json = storage
382        .put_item_with_hash(&put.table_name, &pk, &sk, &item_json, size, &hash_prefix)
383        .await?;
384
385    let _ = super::gsi::maintain_gsis_after_write(
386        storage,
387        &put.table_name,
388        &meta,
389        &pk,
390        &sk,
391        &item,
392        &key_schema.partition_key,
393        key_schema.sort_key.as_deref(),
394    )
395    .await?;
396
397    super::lsi::maintain_lsis_after_write(
398        storage,
399        &put.table_name,
400        &meta,
401        &pk,
402        &sk,
403        &item,
404        &key_schema.partition_key,
405        key_schema.sort_key.as_deref(),
406    )
407    .await?;
408
409    // Record stream event
410    let old_item: Option<Item> = old_json.and_then(|j| serde_json::from_str(&j).ok());
411    crate::streams::record_stream_event(storage, &meta, old_item.as_ref(), Some(&item)).await?;
412
413    Ok(())
414}
415
416async fn execute_update<S: StorageBackend>(storage: &S, update: &TransactUpdate) -> Result<()> {
417    crate::validation::validate_table_name(&update.table_name)?;
418    let meta = helpers::require_table_for_item_op(storage, &update.table_name).await?;
419    let key_schema = helpers::parse_key_schema(&meta)?;
420
421    helpers::validate_key_only(&update.key, &key_schema)?;
422    // TODO: validation must precede this call -- if reaching this line, caller has already validated keys.
423    let (pk, sk) = helpers::extract_key_strings(&update.key, &key_schema)?;
424
425    let existing_json = storage.get_item(&update.table_name, &pk, &sk).await?;
426    let existing_item: Item = existing_json
427        .as_ref()
428        .and_then(|j| serde_json::from_str(j).ok())
429        .unwrap_or_default();
430
431    validate_eav_nesting(&update.expression_attribute_values)?;
432
433    let tracker = crate::expressions::TrackedExpressionAttributes::new(
434        &update.expression_attribute_names,
435        &update.expression_attribute_values,
436    );
437
438    // Pre-register references statically before runtime evaluation
439    if let Some(ref cond_expr) = update.condition_expression {
440        if let Ok(parsed) = crate::expressions::condition::parse(cond_expr) {
441            tracker.track_condition_expr(&parsed);
442        }
443    }
444    if let Ok(parsed) = crate::expressions::update::parse(&update.update_expression) {
445        tracker.track_update_expr(&parsed);
446    }
447
448    // Evaluate condition against the original existing item BEFORE populating
449    // key attributes for upsert. Otherwise attribute_exists(PK) would always
450    // pass because the key was pre-populated.
451    if let Some(ref cond_expr) = update.condition_expression {
452        let return_item = if update.return_values_on_condition_check_failure.as_deref()
453            == Some("ALL_OLD")
454            && existing_json.is_some()
455        {
456            Some(existing_item.clone())
457        } else {
458            None
459        };
460        check_condition_tracked(cond_expr, &existing_item, &tracker, return_item)?;
461    }
462
463    // Build the mutable item for the update expression.
464    // If new item (upsert), populate key attrs.
465    let mut item = existing_item;
466    if existing_json.is_none() {
467        for (k, v) in &update.key {
468            item.insert(k.clone(), v.clone());
469        }
470    }
471    let before_item = item.clone();
472
473    // Apply update expression
474    let parsed = crate::expressions::update::parse(&update.update_expression)
475        .map_err(DynoxideError::ValidationException)?;
476    crate::expressions::update::apply(&mut item, &parsed, &tracker)
477        .map_err(DynoxideError::ValidationException)?;
478
479    tracker.check_unused()?;
480
481    // Validate attribute values after update expression applied
482    crate::validation::validate_item_attribute_values(&item)?;
483    crate::validation::normalize_item_sets(&mut item);
484
485    // Reject an index key this update set to an invalid value (see helpers).
486    helpers::validate_updated_index_keys(&before_item, &item, &meta)?;
487
488    let size = types::item_size(&item);
489    if size > types::MAX_ITEM_SIZE {
490        return Err(DynoxideError::ValidationException(
491            "Item size has exceeded the maximum allowed size".to_string(),
492        ));
493    }
494
495    // Save old item reference for streams
496    let old_for_stream = existing_json.clone();
497
498    let item_json = serde_json::to_string(&item)
499        .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
500    let hash_prefix = update
501        .key
502        .get(&key_schema.partition_key)
503        .map(crate::storage::compute_hash_prefix)
504        .unwrap_or_default();
505    storage
506        .put_item_with_hash(&update.table_name, &pk, &sk, &item_json, size, &hash_prefix)
507        .await?;
508
509    let _ = super::gsi::maintain_gsis_after_write(
510        storage,
511        &update.table_name,
512        &meta,
513        &pk,
514        &sk,
515        &item,
516        &key_schema.partition_key,
517        key_schema.sort_key.as_deref(),
518    )
519    .await?;
520
521    super::lsi::maintain_lsis_after_write(
522        storage,
523        &update.table_name,
524        &meta,
525        &pk,
526        &sk,
527        &item,
528        &key_schema.partition_key,
529        key_schema.sort_key.as_deref(),
530    )
531    .await?;
532
533    // Record stream event
534    let old_item: Option<Item> = old_for_stream.and_then(|j| serde_json::from_str(&j).ok());
535    crate::streams::record_stream_event(storage, &meta, old_item.as_ref(), Some(&item)).await?;
536
537    Ok(())
538}
539
540async fn execute_delete<S: StorageBackend>(storage: &S, delete: &TransactDelete) -> Result<()> {
541    crate::validation::validate_table_name(&delete.table_name)?;
542    let meta = helpers::require_table_for_item_op(storage, &delete.table_name).await?;
543    let key_schema = helpers::parse_key_schema(&meta)?;
544
545    helpers::validate_key_only(&delete.key, &key_schema)?;
546    // TODO: validation must precede this call -- if reaching this line, caller has already validated keys.
547    let (pk, sk) = helpers::extract_key_strings(&delete.key, &key_schema)?;
548
549    validate_eav_nesting(&delete.expression_attribute_values)?;
550
551    let tracker = crate::expressions::TrackedExpressionAttributes::new(
552        &delete.expression_attribute_names,
553        &delete.expression_attribute_values,
554    );
555
556    // Pre-register references statically before runtime evaluation
557    if let Some(ref cond_expr) = delete.condition_expression {
558        if let Ok(parsed) = crate::expressions::condition::parse(cond_expr) {
559            tracker.track_condition_expr(&parsed);
560        }
561    }
562
563    // Evaluate condition if present
564    if let Some(ref cond_expr) = delete.condition_expression {
565        let existing_json = storage.get_item(&delete.table_name, &pk, &sk).await?;
566        let existing_item: Item = existing_json
567            .as_ref()
568            .and_then(|j| serde_json::from_str(j).ok())
569            .unwrap_or_default();
570
571        let return_item = if delete.return_values_on_condition_check_failure.as_deref()
572            == Some("ALL_OLD")
573            && !existing_item.is_empty()
574        {
575            Some(existing_item.clone())
576        } else {
577            None
578        };
579        check_condition_tracked(cond_expr, &existing_item, &tracker, return_item)?;
580    }
581
582    tracker.check_unused()?;
583
584    let old_json = storage.delete_item(&delete.table_name, &pk, &sk).await?;
585    let _ = super::gsi::maintain_gsis_after_delete(storage, &delete.table_name, &meta, &pk, &sk)
586        .await?;
587    super::lsi::maintain_lsis_after_delete(storage, &delete.table_name, &meta, &pk, &sk).await?;
588
589    // Record stream event
590    let old_item: Option<Item> = old_json.and_then(|j| serde_json::from_str(&j).ok());
591    if old_item.is_some() {
592        crate::streams::record_stream_event(storage, &meta, old_item.as_ref(), None).await?;
593    }
594
595    Ok(())
596}
597
598async fn execute_condition_check<S: StorageBackend>(
599    storage: &S,
600    check: &TransactConditionCheck,
601) -> Result<()> {
602    crate::validation::validate_table_name(&check.table_name)?;
603    let meta = helpers::require_table_for_item_op(storage, &check.table_name).await?;
604    let key_schema = helpers::parse_key_schema(&meta)?;
605
606    helpers::validate_key_only(&check.key, &key_schema)?;
607    // TODO: validation must precede this call -- if reaching this line, caller has already validated keys.
608    let (pk, sk) = helpers::extract_key_strings(&check.key, &key_schema)?;
609
610    let existing_json = storage.get_item(&check.table_name, &pk, &sk).await?;
611    let existing_item: Item = existing_json
612        .as_ref()
613        .and_then(|j| serde_json::from_str(j).ok())
614        .unwrap_or_default();
615
616    validate_eav_nesting(&check.expression_attribute_values)?;
617
618    let tracker = crate::expressions::TrackedExpressionAttributes::new(
619        &check.expression_attribute_names,
620        &check.expression_attribute_values,
621    );
622
623    // Pre-register references statically before runtime evaluation
624    if let Ok(parsed) = crate::expressions::condition::parse(&check.condition_expression) {
625        tracker.track_condition_expr(&parsed);
626    }
627
628    let return_item = if check.return_values_on_condition_check_failure.as_deref()
629        == Some("ALL_OLD")
630        && !existing_item.is_empty()
631    {
632        Some(existing_item.clone())
633    } else {
634        None
635    };
636    check_condition_tracked(
637        &check.condition_expression,
638        &existing_item,
639        &tracker,
640        return_item,
641    )?;
642
643    tracker.check_unused()?;
644    Ok(())
645}
646
647fn check_condition_tracked(
648    expression: &str,
649    item: &Item,
650    tracker: &crate::expressions::TrackedExpressionAttributes,
651    return_item_on_failure: Option<Item>,
652) -> Result<()> {
653    let parsed = crate::expressions::condition::parse(expression)
654        .map_err(DynoxideError::ValidationException)?;
655    let result = crate::expressions::condition::evaluate(&parsed, item, tracker)
656        .map_err(DynoxideError::ValidationException)?;
657    if !result {
658        return Err(DynoxideError::ConditionalCheckFailedException(
659            "The conditional request failed".to_string(),
660            return_item_on_failure,
661        ));
662    }
663    Ok(())
664}
665
666/// Get table name and estimated item size for an action.
667///
668/// For Put, uses the full item size. For Update, includes both the key size
669/// and the expression attribute values size (a better approximation of the
670/// request payload contribution). For Delete and ConditionCheck, uses key size.
671fn get_action_table_and_size(item: &TransactWriteItem) -> (String, usize) {
672    if let Some(ref put) = item.put {
673        (put.table_name.clone(), types::item_size(&put.item))
674    } else if let Some(ref update) = item.update {
675        let key_size = types::item_size(&update.key);
676        let eav_size = update
677            .expression_attribute_values
678            .as_ref()
679            .map(|vals| vals.values().map(|v| v.size()).sum::<usize>())
680            .unwrap_or(0);
681        (update.table_name.clone(), key_size + eav_size)
682    } else if let Some(ref delete) = item.delete {
683        (delete.table_name.clone(), types::item_size(&delete.key))
684    } else if let Some(ref check) = item.condition_check {
685        (check.table_name.clone(), types::item_size(&check.key))
686    } else {
687        (String::new(), 0)
688    }
689}
690
691/// Compute the dedup target (table + pk + sk) for one action's key source, or `None`
692/// when the key can't be stringified (non-scalar or missing). Table name and existence
693/// are still validated, so a bad name or missing table surfaces up front.
694async fn target_for<S: StorageBackend>(
695    storage: &S,
696    table_name: &str,
697    key_source: &HashMap<String, AttributeValue>,
698) -> Result<Option<String>> {
699    crate::validation::validate_table_name(table_name)?;
700    let meta = helpers::require_table_for_item_op(storage, table_name).await?;
701    let key_schema = helpers::parse_key_schema(&meta)?;
702    match helpers::extract_key_strings(key_source, &key_schema) {
703        Ok((pk, sk)) => Ok(Some(format!("{table_name}#{pk}#{sk}"))),
704        Err(_) => Ok(None),
705    }
706}
707
708/// Get a unique target key (table + pk + sk) for duplicate detection, or `None` when
709/// the action's key can't form one (see [`target_for`]).
710async fn get_item_target<S: StorageBackend>(
711    storage: &S,
712    item: &TransactWriteItem,
713) -> Result<Option<String>> {
714    if let Some(ref put) = item.put {
715        target_for(storage, &put.table_name, &put.item).await
716    } else if let Some(ref update) = item.update {
717        target_for(storage, &update.table_name, &update.key).await
718    } else if let Some(ref delete) = item.delete {
719        target_for(storage, &delete.table_name, &delete.key).await
720    } else if let Some(ref check) = item.condition_check {
721        target_for(storage, &check.table_name, &check.key).await
722    } else {
723        Err(DynoxideError::ValidationException(
724            "TransactItem must contain exactly one action".to_string(),
725        ))
726    }
727}