Skip to main content

dynoxide/actions/
transact_write_items.rs

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