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,
deserialize_with = "crate::types::deserialize_parameters"
)]
pub parameters: Option<Vec<AttributeValue>>,
#[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
pub return_values_on_condition_check_failure: Option<String>,
}
#[derive(Serialize)]
pub(crate) struct TokenStatement<'a> {
#[serde(rename = "Statement")]
statement: &'a str,
#[serde(rename = "Parameters")]
parameters: Option<&'a [AttributeValue]>,
#[serde(rename = "ReturnValuesOnConditionCheckFailure")]
mode: &'a str,
}
pub(crate) fn token_statements(statements: &[ParameterizedStatement]) -> Vec<TokenStatement<'_>> {
statements
.iter()
.map(|s| TokenStatement {
statement: &s.statement,
parameters: s.parameters.as_deref(),
mode: s
.return_values_on_condition_check_failure
.as_deref()
.unwrap_or("NONE"),
})
.collect()
}
#[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",
serialize_with = "crate::set_order::item_in_hash_order"
)]
pub item: Option<Item>,
}
pub async fn execute<S: StorageBackend>(
storage: &S,
request: ExecuteTransactionRequest,
) -> Result<ExecuteTransactionResponse> {
Ok(execute_cached(storage, request).await?.response)
}
fn statements_echo(statements: &[ParameterizedStatement]) -> String {
let mut out = crate::actions::batch_write_item::BoundedEcho::new();
let _ = write_statements(&mut out, statements);
out.finish()
}
fn write_statements(
out: &mut dyn std::fmt::Write,
statements: &[ParameterizedStatement],
) -> std::fmt::Result {
out.write_str("[")?;
for (i, statement) in statements.iter().enumerate() {
if i > 0 {
out.write_str(", ")?;
}
write!(
out,
"ParameterizedStatement(statement={}, parameters=",
statement.statement
)?;
match statement.parameters {
Some(ref parameters) => {
out.write_str("[")?;
for (j, parameter) in parameters.iter().enumerate() {
if j > 0 {
out.write_str(", ")?;
}
crate::actions::batch_write_item::write_java_attribute_value(out, parameter)?;
}
out.write_str("]")?;
}
None => out.write_str("null")?,
}
out.write_str(", returnValuesOnConditionCheckFailure=")?;
out.write_str(
statement
.return_values_on_condition_check_failure
.as_deref()
.unwrap_or("null"),
)?;
out.write_str(")")?;
}
out.write_str("]")
}
fn member_violations(request: &ExecuteTransactionRequest) -> Vec<String> {
let mut violations = Vec::new();
violations.extend(crate::token_constraint_error(
request.client_request_token.as_deref(),
));
if request.transact_statements.is_empty() {
violations.push(
"Value '[]' at 'transactStatements' failed to satisfy constraint: Member must \
have length greater than or equal to 1"
.to_string(),
);
}
if request.transact_statements.len() > 100 {
violations.push(format!(
"Value '{}' at 'transactStatements' failed to satisfy constraint: Member must \
have length less than or equal to 100",
statements_echo(&request.transact_statements)
));
}
for (i, statement) in request.transact_statements.iter().enumerate() {
let member = format!("transactStatements.{}.member", i + 1);
violations.extend(
crate::validation::return_values_on_condition_check_failure_rejection(
statement
.return_values_on_condition_check_failure
.as_deref(),
&format!("{member}.returnValuesOnConditionCheckFailure"),
),
);
violations.extend(crate::partiql::statement_length_violation(
&statement.statement,
&format!("{member}.statement"),
));
violations.extend(crate::partiql::parameters_violation(
statement.parameters.as_deref(),
&format!("{member}.parameters"),
));
}
violations.extend(crate::validation::return_consumed_capacity_rejection(
request.return_consumed_capacity.as_deref(),
));
violations
}
pub(crate) fn check_members(request: &ExecuteTransactionRequest) -> Result<()> {
match crate::partiql::member_rejection(&member_violations(request)) {
Some(e) => Err(e),
None => Ok(()),
}
}
pub(crate) async fn execute_cached<S: StorageBackend>(
storage: &S,
request: ExecuteTransactionRequest,
) -> Result<CachedTransaction> {
check_members(&request)?;
let statements = &request.transact_statements;
if statements
.iter()
.any(|s| crate::empty_names::parameters_have_empty_name(s.parameters.as_deref()))
{
return Err(crate::empty_names::bare());
}
for stmt in statements {
for parameter in stmt.parameters.iter().flatten() {
partiql::executor::check_parameter_value(parameter)?;
}
}
let mut parsed = Vec::with_capacity(statements.len());
for (index, stmt) in statements.iter().enumerate() {
if crate::value_nesting::parameters_nest_too_deep(
stmt.parameters.as_deref().unwrap_or_default(),
) {
return Err(crate::value_nesting::deep_parameter_error());
}
if let Some(msg) = partiql::parser::count_projection_rejection(&stmt.statement) {
return Err(DynoxideError::ValidationException(format!(
"Validation failed in TransactStatements[{index}]: {msg}"
)));
}
let supplied = stmt.parameters.as_ref().map_or(0, Vec::len);
let ast = partiql::parser::parse_with_parameters(&stmt.statement, supplied).map_err(
|failure| {
let message = match failure {
partiql::parser::ParseFailure::Statement(e) => e.into_message(),
partiql::parser::ParseFailure::Literal(message) => message,
};
DynoxideError::ValidationException(format!(
"Validation failed in TransactStatements[{index}]: {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 (index, (stmt, params)) in parsed.iter().enumerate() {
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(partiql::executor::KeyFault::NotAKeyType(message)) =
partiql::executor::write_key_fault(stmt, params, resolved)
{
return Err(DynoxideError::ValidationException(format!(
"Validation failed in TransactStatements[{index}]: {message}"
)));
}
if matches!(stmt, partiql::parser::Statement::Select { .. })
&& let Err(e) = partiql::executor::reject_overlapping_select(stmt, params, resolved)
{
return Err(DynoxideError::ValidationException(format!(
"Validation failed in TransactStatements[{index}]: {e}"
)));
}
if matches!(stmt, partiql::parser::Statement::Select { .. })
&& matches!(
partiql::executor::keyed_select(stmt, params, resolved),
partiql::executor::KeyedSelect::NotByPrimaryKey
)
{
return Err(DynoxideError::ValidationException(format!(
"Validation failed in TransactStatements[{index}]: Select statements within \
ExecuteTransaction must specify the primary key in the where clause."
)));
}
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,
statements,
&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)
};
let responses = if is_read_set(&parsed) {
responses
} else {
Vec::new()
};
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,
statements: &[ParameterizedStatement],
parsed: &[(partiql::parser::Statement, Vec<AttributeValue>)],
tables: &HashMap<String, ResolvedTable>,
capacity_mode: Option<&str>,
) -> Result<(Vec<ItemResponse>, Vec<StatementCharge>)> {
let resolved_for = |stmt: &partiql::parser::Statement| {
partiql::parser::table_name(stmt).and_then(|t| tables.get(t))
};
let wrong_key_type: Vec<bool> = parsed
.iter()
.map(|(stmt, params)| {
resolved_for(stmt).is_some_and(|resolved| match stmt {
partiql::parser::Statement::Select { .. } => matches!(
partiql::executor::keyed_select(stmt, params, resolved),
partiql::executor::KeyedSelect::KeyType(_)
),
_ => partiql::executor::write_key_fault(stmt, params, resolved).is_some(),
})
})
.collect();
if wrong_key_type.contains(&true) {
return Err(cancelled(
wrong_key_type
.iter()
.map(|&wrong| {
if wrong {
CancellationReason {
code: "ValidationError".to_string(),
message: Some(
"The provided key element does not match the schema".to_string(),
),
item: None,
}
} else {
no_reason()
}
})
.collect(),
));
}
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());
let mut failed = false;
for ((stmt, params), written) in parsed.iter().zip(statements) {
let resolved = resolved_for(stmt);
let old_on_failure =
written.return_values_on_condition_check_failure.as_deref() == Some("ALL_OLD");
let insert_keys = match resolved {
Some(resolved) => {
partiql::executor::check_transaction_insert_keys(stmt, params, resolved)
}
None => Ok(()),
};
let outcome = match insert_keys {
Err(e) => Err(e),
Ok(()) => {
partiql::executor::execute_written_page(
storage,
&written.statement,
stmt,
params,
None,
None,
false,
capacity_mode,
resolved,
old_on_failure,
)
.await
}
};
match outcome {
Ok(page) => {
let missed_condition = matches!(stmt, partiql::parser::Statement::Delete { .. })
&& page
.capacity
.as_ref()
.is_some_and(|capacity| capacity.old_size.is_none())
&& resolved.is_some_and(|resolved| {
partiql::executor::delete_has_condition(stmt, resolved)
});
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(if missed_condition {
CancellationReason {
code: "ConditionalCheckFailed".to_string(),
message: Some("The conditional request failed".to_string()),
item: None,
}
} else {
no_reason()
});
}
Err(e) => {
let Some(code) = outcome_code(&e) else {
return Err(e);
};
failed = true;
responses.push(ItemResponse { item: None });
cancellation_reasons.push(failure_reason(code, e));
}
}
}
if failed {
return Err(cancelled(cancellation_reasons));
}
Ok((responses, charges))
}
fn no_reason() -> CancellationReason {
CancellationReason {
code: "None".to_string(),
message: None,
item: None,
}
}
fn outcome_code(e: &DynoxideError) -> Option<&'static str> {
match e {
DynoxideError::ConditionalCheckFailedException(..) => Some("ConditionalCheckFailed"),
DynoxideError::DuplicateItemException(_) => Some("DuplicateItem"),
DynoxideError::ResourceNotFoundException(_) => Some("ResourceNotFound"),
DynoxideError::ValidationException(_) | DynoxideError::KeyEmptyValueValidation(_) => {
Some("ValidationError")
}
_ => None,
}
}
fn failure_reason(code: &str, e: DynoxideError) -> CancellationReason {
let message = Some(e.to_string());
let item = match e {
DynoxideError::ConditionalCheckFailedException(_, item) => item,
_ => None,
};
CancellationReason {
code: code.to_string(),
message,
item,
}
}
fn cancelled(reasons: Vec<CancellationReason>) -> DynoxideError {
let codes: Vec<&str> = reasons.iter().map(|r| r.code.as_str()).collect();
let message = format!(
"Transaction cancelled, please refer cancellation reasons for specific reasons [{}]",
codes.join(", ")
);
DynoxideError::TransactionCanceledException(message, reasons)
}