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::partiql;
7use crate::partiql::executor::ResolvedTable;
8use crate::storage_backend::StorageBackend;
9use crate::types::{AttributeValue, Item};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13#[derive(Debug, Clone, Default, Deserialize)]
14pub struct ExecuteTransactionRequest {
15 #[serde(rename = "TransactStatements")]
16 pub transact_statements: Vec<ParameterizedStatement>,
17 #[serde(rename = "ClientRequestToken", default)]
18 pub client_request_token: Option<String>,
19 #[serde(rename = "ReturnConsumedCapacity", default)]
20 pub return_consumed_capacity: Option<String>,
21}
22
23#[derive(Debug, Clone, Default, Deserialize, Serialize)]
27pub struct ParameterizedStatement {
28 #[serde(rename = "Statement")]
29 pub statement: String,
30 #[serde(rename = "Parameters", default)]
31 pub parameters: Option<Vec<AttributeValue>>,
32}
33
34#[derive(Debug, Clone, Default, Serialize)]
37pub struct ExecuteTransactionResponse {
38 #[serde(rename = "Responses", skip_serializing_if = "Option::is_none")]
39 pub responses: Option<Vec<ItemResponse>>,
40 #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
41 pub consumed_capacity: Option<Vec<crate::types::ConsumedCapacity>>,
42}
43
44#[derive(Debug, Clone, Default)]
51pub(crate) struct CachedTransaction {
52 pub(crate) response: ExecuteTransactionResponse,
53 pub(crate) replay_sizes: Vec<(String, usize)>,
55}
56
57#[derive(Debug, Clone, Default, Serialize)]
58pub struct ItemResponse {
59 #[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
60 pub item: Option<Item>,
61}
62
63pub async fn execute<S: StorageBackend>(
68 storage: &S,
69 request: ExecuteTransactionRequest,
70) -> Result<ExecuteTransactionResponse> {
71 Ok(execute_cached(storage, request).await?.response)
72}
73
74pub(crate) fn reject_bad_capacity_mode(mode: Option<&str>) -> Result<()> {
83 match crate::validation::return_consumed_capacity_rejection(mode) {
84 Some(msg) => Err(DynoxideError::ValidationException(
85 crate::validation::envelope_message(&msg),
86 )),
87 None => Ok(()),
88 }
89}
90
91pub(crate) async fn execute_cached<S: StorageBackend>(
92 storage: &S,
93 request: ExecuteTransactionRequest,
94) -> Result<CachedTransaction> {
95 reject_bad_capacity_mode(request.return_consumed_capacity.as_deref())?;
96
97 let statements = &request.transact_statements;
98
99 if statements.is_empty() {
101 return Err(DynoxideError::ValidationException(
102 "1 validation error detected: Value at 'transactStatements' failed to satisfy constraint: Member must have length greater than or equal to 1".to_string(),
103 ));
104 }
105 if statements.len() > 100 {
106 return Err(DynoxideError::ValidationException(
107 "Member must have length less than or equal to 100".to_string(),
108 ));
109 }
110
111 let mut parsed = Vec::with_capacity(statements.len());
113 for (index, stmt) in statements.iter().enumerate() {
114 if let Some(msg) = partiql::parser::count_projection_rejection(&stmt.statement) {
118 return Err(DynoxideError::ValidationException(msg));
119 }
120 let ast = partiql::parser::parse(&stmt.statement)
121 .map_err(|e| DynoxideError::ValidationException(e.into_message()))?;
122 if partiql::parser::returning_variant(&ast).is_some() {
126 return Err(DynoxideError::ValidationException(format!(
127 "Validation failed in TransactStatements[{index}]: RETURNING clause is not supported in ExecuteTransaction."
128 )));
129 }
130 if matches!(ast, partiql::parser::Statement::Select { .. })
134 && partiql::parser::index_name(&ast).is_some()
135 {
136 return Err(DynoxideError::ValidationException(format!(
137 "Validation failed in TransactStatements[{index}]: Reads on indices are not supported within transactions."
138 )));
139 }
140 let params = stmt.parameters.clone().unwrap_or_default();
141 parsed.push((ast, params));
142 }
143
144 let reads = parsed
147 .iter()
148 .filter(|(stmt, _)| matches!(stmt, partiql::parser::Statement::Select { .. }))
149 .count();
150 if reads > 0 && reads < parsed.len() {
151 return Err(DynoxideError::ValidationException(
152 "ExecuteTransaction API does not support both read and write operations in the same request."
153 .to_string(),
154 ));
155 }
156
157 let mut seen_targets = HashSet::new();
161 let mut tables: HashMap<String, ResolvedTable> = HashMap::new();
164 for (stmt, params) in &parsed {
165 let Some(name) = partiql::parser::table_name(stmt) else {
166 continue;
167 };
168 if !tables.contains_key(name)
169 && let Ok(resolved) = ResolvedTable::load(storage, name).await
170 {
171 tables.insert(name.to_string(), resolved);
172 }
173 let Some(resolved) = tables.get(name) else {
176 continue;
177 };
178 if let Some(target) = partiql::executor::statement_target_in(stmt, params, resolved)
179 && !seen_targets.insert(target)
180 {
181 return Err(DynoxideError::ValidationException(
182 "Transaction request cannot include multiple operations on one item".to_string(),
183 ));
184 }
185 }
186
187 let (responses, charges) = helpers::with_write_transaction(
189 storage,
190 execute_within_transaction(
191 storage,
192 &parsed,
193 &tables,
194 request.return_consumed_capacity.as_deref(),
195 ),
196 )
197 .await?;
198
199 let mode = &request.return_consumed_capacity;
204 let consumed_capacity = if is_read_set(&parsed) {
205 crate::types::build_transactional_capacity(
206 &transactional_read_units(&replay_sizes(&charges)),
207 mode,
208 crate::types::transactional_read_capacity,
209 )
210 } else {
211 build_write_capacity(&charges, mode)
212 };
213
214 Ok(CachedTransaction {
215 response: ExecuteTransactionResponse {
216 responses: Some(responses),
217 consumed_capacity,
218 },
219 replay_sizes: replay_sizes(&charges),
220 })
221}
222
223enum StatementCharge {
225 Read { table_name: String, size: usize },
227 Write(WriteCapacity),
229}
230
231impl StatementCharge {
232 fn table_name(&self) -> &str {
233 match self {
234 Self::Read { table_name, .. } => table_name,
235 Self::Write(capacity) => &capacity.table_name,
236 }
237 }
238
239 fn size(&self) -> usize {
241 match self {
242 Self::Read { size, .. } => *size,
243 Self::Write(capacity) => capacity
244 .old_size
245 .unwrap_or(0)
246 .max(capacity.new_size.unwrap_or(0)),
247 }
248 }
249
250 fn as_write(&self) -> WriteCapacity {
257 match self {
258 Self::Write(capacity) => capacity.clone(),
259 Self::Read { table_name, size } => WriteCapacity::new(
260 table_name,
261 None,
262 Some(*size),
263 HashMap::new(),
264 HashMap::new(),
265 ),
266 }
267 }
268}
269
270fn build_write_capacity(
272 charges: &[StatementCharge],
273 mode: &Option<String>,
274) -> Option<Vec<crate::types::ConsumedCapacity>> {
275 if !matches!(mode.as_deref(), Some("TOTAL") | Some("INDEXES")) {
279 return None;
280 }
281
282 let records: Vec<WriteCapacity> = charges.iter().map(StatementCharge::as_write).collect();
283 let by_table = aggregate_by_table(&records, crate::types::TRANSACTIONAL_CAPACITY_FACTOR);
284 per_table_capacity(
285 &by_table,
286 mode,
287 crate::types::transactional_write_capacity_with_indexes,
288 )
289}
290
291fn replay_sizes(charges: &[StatementCharge]) -> Vec<(String, usize)> {
293 charges
294 .iter()
295 .map(|charge| (charge.table_name().to_string(), charge.size()))
296 .collect()
297}
298
299fn is_read_set(parsed: &[(partiql::parser::Statement, Vec<AttributeValue>)]) -> bool {
306 parsed
307 .iter()
308 .all(|(stmt, _)| matches!(stmt, partiql::parser::Statement::Select { .. }))
309}
310
311pub(crate) fn replay_response(
320 cached: &CachedTransaction,
321 mode: &Option<String>,
322) -> CachedTransaction {
323 CachedTransaction {
324 response: ExecuteTransactionResponse {
325 responses: cached.response.responses.clone(),
326 consumed_capacity: crate::types::build_transactional_capacity(
327 &transactional_read_units(&cached.replay_sizes),
328 mode,
329 crate::types::transactional_read_capacity,
330 ),
331 },
332 replay_sizes: cached.replay_sizes.clone(),
333 }
334}
335
336async fn execute_within_transaction<S: StorageBackend>(
340 storage: &S,
341 parsed: &[(partiql::parser::Statement, Vec<AttributeValue>)],
342 tables: &HashMap<String, ResolvedTable>,
343 capacity_mode: Option<&str>,
344) -> Result<(Vec<ItemResponse>, Vec<StatementCharge>)> {
345 let mut responses = Vec::with_capacity(parsed.len());
346 let mut charges: Vec<StatementCharge> = Vec::with_capacity(parsed.len());
347 let mut cancellation_reasons: Vec<CancellationReason> = Vec::with_capacity(parsed.len());
348
349 for (stmt, params) in parsed {
350 match partiql::executor::execute_page(
351 storage,
352 stmt,
353 params,
354 None,
355 None,
356 false,
357 capacity_mode,
358 partiql::parser::table_name(stmt).and_then(|t| tables.get(t)),
359 )
360 .await
361 {
362 Ok(page) => {
363 let table_name = partiql::parser::table_name(stmt).unwrap_or_default();
364 charges.push(match page.capacity {
365 Some(capacity) => StatementCharge::Write(capacity),
366 None => StatementCharge::Read {
367 table_name: table_name.to_string(),
368 size: page.size,
369 },
370 });
371 let item = page.items.and_then(|items| items.into_iter().next());
372 responses.push(ItemResponse { item });
373 cancellation_reasons.push(CancellationReason {
374 code: "None".to_string(),
375 message: None,
376 item: None,
377 });
378 }
379 Err(e) => {
380 let message = Some(e.to_string());
382 let (code, item) = match e {
383 DynoxideError::ConditionalCheckFailedException(_, item) => {
384 ("ConditionalCheckFailed".to_string(), item)
385 }
386 DynoxideError::DuplicateItemException(_) => ("DuplicateItem".to_string(), None),
387 DynoxideError::ValidationException(_)
391 | DynoxideError::KeyEmptyValueValidation(_) => {
392 ("ValidationError".to_string(), None)
393 }
394 _ => ("InternalError".to_string(), None),
395 };
396 responses.push(ItemResponse { item: None });
397 cancellation_reasons.push(CancellationReason {
398 code,
399 message,
400 item,
401 });
402
403 for _ in responses.len()..parsed.len() {
406 responses.push(ItemResponse { item: None });
407 cancellation_reasons.push(CancellationReason {
408 code: "None".to_string(),
409 message: None,
410 item: None,
411 });
412 }
413
414 let codes: Vec<&str> = cancellation_reasons
415 .iter()
416 .map(|r| r.code.as_str())
417 .collect();
418 let message = format!(
419 "Transaction cancelled, please refer cancellation reasons for specific reasons [{}]",
420 codes.join(", ")
421 );
422 return Err(DynoxideError::TransactionCanceledException(
423 message,
424 cancellation_reasons,
425 ));
426 }
427 }
428 }
429
430 Ok((responses, charges))
431}