use crate::actions::helpers;
use crate::actions::index_capacity::{
WriteCapacity, aggregate_by_table, per_table_capacity, transactional_read_units,
};
use crate::errors::{CancellationReason, DynoxideError, Result};
use crate::partiql;
use crate::partiql::executor::ResolvedTable;
use crate::storage_backend::StorageBackend;
use crate::types::{AttributeValue, Item};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ExecuteTransactionRequest {
#[serde(rename = "TransactStatements")]
pub transact_statements: Vec<ParameterizedStatement>,
#[serde(rename = "ClientRequestToken", default)]
pub client_request_token: Option<String>,
#[serde(rename = "ReturnConsumedCapacity", default)]
pub return_consumed_capacity: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ParameterizedStatement {
#[serde(rename = "Statement")]
pub statement: String,
#[serde(rename = "Parameters", default)]
pub parameters: Option<Vec<AttributeValue>>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ExecuteTransactionResponse {
#[serde(rename = "Responses", skip_serializing_if = "Option::is_none")]
pub responses: Option<Vec<ItemResponse>>,
#[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
pub consumed_capacity: Option<Vec<crate::types::ConsumedCapacity>>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct CachedTransaction {
pub(crate) response: ExecuteTransactionResponse,
pub(crate) replay_sizes: Vec<(String, usize)>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ItemResponse {
#[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
pub item: Option<Item>,
}
pub async fn execute<S: StorageBackend>(
storage: &S,
request: ExecuteTransactionRequest,
) -> Result<ExecuteTransactionResponse> {
Ok(execute_cached(storage, request).await?.response)
}
pub(crate) fn reject_bad_capacity_mode(mode: Option<&str>) -> Result<()> {
match crate::validation::return_consumed_capacity_rejection(mode) {
Some(msg) => Err(DynoxideError::ValidationException(
crate::validation::envelope_message(&msg),
)),
None => Ok(()),
}
}
pub(crate) async fn execute_cached<S: StorageBackend>(
storage: &S,
request: ExecuteTransactionRequest,
) -> Result<CachedTransaction> {
reject_bad_capacity_mode(request.return_consumed_capacity.as_deref())?;
let statements = &request.transact_statements;
if statements.is_empty() {
return Err(DynoxideError::ValidationException(
"1 validation error detected: Value at 'transactStatements' failed to satisfy constraint: Member must have length greater than or equal to 1".to_string(),
));
}
if statements.len() > 100 {
return Err(DynoxideError::ValidationException(
"Member must have length less than or equal to 100".to_string(),
));
}
let mut parsed = Vec::with_capacity(statements.len());
for (index, stmt) in statements.iter().enumerate() {
if let Some(msg) = partiql::parser::count_projection_rejection(&stmt.statement) {
return Err(DynoxideError::ValidationException(msg));
}
let ast = partiql::parser::parse(&stmt.statement)
.map_err(|e| DynoxideError::ValidationException(e.into_message()))?;
if partiql::parser::returning_variant(&ast).is_some() {
return Err(DynoxideError::ValidationException(format!(
"Validation failed in TransactStatements[{index}]: RETURNING clause is not supported in ExecuteTransaction."
)));
}
if matches!(ast, partiql::parser::Statement::Select { .. })
&& partiql::parser::index_name(&ast).is_some()
{
return Err(DynoxideError::ValidationException(format!(
"Validation failed in TransactStatements[{index}]: Reads on indices are not supported within transactions."
)));
}
let params = stmt.parameters.clone().unwrap_or_default();
parsed.push((ast, params));
}
let reads = parsed
.iter()
.filter(|(stmt, _)| matches!(stmt, partiql::parser::Statement::Select { .. }))
.count();
if reads > 0 && reads < parsed.len() {
return Err(DynoxideError::ValidationException(
"ExecuteTransaction API does not support both read and write operations in the same request."
.to_string(),
));
}
let mut seen_targets = HashSet::new();
let mut tables: HashMap<String, ResolvedTable> = HashMap::new();
for (stmt, params) in &parsed {
let Some(name) = partiql::parser::table_name(stmt) else {
continue;
};
if !tables.contains_key(name)
&& let Ok(resolved) = ResolvedTable::load(storage, name).await
{
tables.insert(name.to_string(), resolved);
}
let Some(resolved) = tables.get(name) else {
continue;
};
if let Some(target) = partiql::executor::statement_target_in(stmt, params, resolved)
&& !seen_targets.insert(target)
{
return Err(DynoxideError::ValidationException(
"Transaction request cannot include multiple operations on one item".to_string(),
));
}
}
let (responses, charges) = helpers::with_write_transaction(
storage,
execute_within_transaction(
storage,
&parsed,
&tables,
request.return_consumed_capacity.as_deref(),
),
)
.await?;
let mode = &request.return_consumed_capacity;
let consumed_capacity = if is_read_set(&parsed) {
crate::types::build_transactional_capacity(
&transactional_read_units(&replay_sizes(&charges)),
mode,
crate::types::transactional_read_capacity,
)
} else {
build_write_capacity(&charges, mode)
};
Ok(CachedTransaction {
response: ExecuteTransactionResponse {
responses: Some(responses),
consumed_capacity,
},
replay_sizes: replay_sizes(&charges),
})
}
enum StatementCharge {
Read { table_name: String, size: usize },
Write(WriteCapacity),
}
impl StatementCharge {
fn table_name(&self) -> &str {
match self {
Self::Read { table_name, .. } => table_name,
Self::Write(capacity) => &capacity.table_name,
}
}
fn size(&self) -> usize {
match self {
Self::Read { size, .. } => *size,
Self::Write(capacity) => capacity
.old_size
.unwrap_or(0)
.max(capacity.new_size.unwrap_or(0)),
}
}
fn as_write(&self) -> WriteCapacity {
match self {
Self::Write(capacity) => capacity.clone(),
Self::Read { table_name, size } => WriteCapacity::new(
table_name,
None,
Some(*size),
HashMap::new(),
HashMap::new(),
),
}
}
}
fn build_write_capacity(
charges: &[StatementCharge],
mode: &Option<String>,
) -> Option<Vec<crate::types::ConsumedCapacity>> {
if !matches!(mode.as_deref(), Some("TOTAL") | Some("INDEXES")) {
return None;
}
let records: Vec<WriteCapacity> = charges.iter().map(StatementCharge::as_write).collect();
let by_table = aggregate_by_table(&records, crate::types::TRANSACTIONAL_CAPACITY_FACTOR);
per_table_capacity(
&by_table,
mode,
crate::types::transactional_write_capacity_with_indexes,
)
}
fn replay_sizes(charges: &[StatementCharge]) -> Vec<(String, usize)> {
charges
.iter()
.map(|charge| (charge.table_name().to_string(), charge.size()))
.collect()
}
fn is_read_set(parsed: &[(partiql::parser::Statement, Vec<AttributeValue>)]) -> bool {
parsed
.iter()
.all(|(stmt, _)| matches!(stmt, partiql::parser::Statement::Select { .. }))
}
pub(crate) fn replay_response(
cached: &CachedTransaction,
mode: &Option<String>,
) -> CachedTransaction {
CachedTransaction {
response: ExecuteTransactionResponse {
responses: cached.response.responses.clone(),
consumed_capacity: crate::types::build_transactional_capacity(
&transactional_read_units(&cached.replay_sizes),
mode,
crate::types::transactional_read_capacity,
),
},
replay_sizes: cached.replay_sizes.clone(),
}
}
async fn execute_within_transaction<S: StorageBackend>(
storage: &S,
parsed: &[(partiql::parser::Statement, Vec<AttributeValue>)],
tables: &HashMap<String, ResolvedTable>,
capacity_mode: Option<&str>,
) -> Result<(Vec<ItemResponse>, Vec<StatementCharge>)> {
let mut responses = Vec::with_capacity(parsed.len());
let mut charges: Vec<StatementCharge> = Vec::with_capacity(parsed.len());
let mut cancellation_reasons: Vec<CancellationReason> = Vec::with_capacity(parsed.len());
for (stmt, params) in parsed {
match partiql::executor::execute_page(
storage,
stmt,
params,
None,
None,
false,
capacity_mode,
partiql::parser::table_name(stmt).and_then(|t| tables.get(t)),
)
.await
{
Ok(page) => {
let table_name = partiql::parser::table_name(stmt).unwrap_or_default();
charges.push(match page.capacity {
Some(capacity) => StatementCharge::Write(capacity),
None => StatementCharge::Read {
table_name: table_name.to_string(),
size: page.size,
},
});
let item = page.items.and_then(|items| items.into_iter().next());
responses.push(ItemResponse { item });
cancellation_reasons.push(CancellationReason {
code: "None".to_string(),
message: None,
item: None,
});
}
Err(e) => {
let message = Some(e.to_string());
let (code, item) = match e {
DynoxideError::ConditionalCheckFailedException(_, item) => {
("ConditionalCheckFailed".to_string(), item)
}
DynoxideError::DuplicateItemException(_) => ("DuplicateItem".to_string(), None),
DynoxideError::ValidationException(_)
| DynoxideError::KeyEmptyValueValidation(_) => {
("ValidationError".to_string(), None)
}
_ => ("InternalError".to_string(), None),
};
responses.push(ItemResponse { item: None });
cancellation_reasons.push(CancellationReason {
code,
message,
item,
});
for _ in responses.len()..parsed.len() {
responses.push(ItemResponse { item: None });
cancellation_reasons.push(CancellationReason {
code: "None".to_string(),
message: None,
item: None,
});
}
let codes: Vec<&str> = cancellation_reasons
.iter()
.map(|r| r.code.as_str())
.collect();
let message = format!(
"Transaction cancelled, please refer cancellation reasons for specific reasons [{}]",
codes.join(", ")
);
return Err(DynoxideError::TransactionCanceledException(
message,
cancellation_reasons,
));
}
}
}
Ok((responses, charges))
}