use crate::actions::index_capacity::{WriteCapacity, aggregate_by_table, per_table_capacity};
use crate::errors::{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, Default, Deserialize)]
pub struct BatchExecuteStatementRequest {
#[serde(rename = "Statements")]
pub statements: Vec<BatchStatementRequest>,
#[serde(rename = "ReturnConsumedCapacity", default)]
pub return_consumed_capacity: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[non_exhaustive]
pub struct BatchStatementRequest {
#[serde(rename = "Statement")]
pub statement: String,
#[serde(rename = "Parameters", default)]
pub parameters: Option<Vec<AttributeValue>>,
#[serde(rename = "ConsistentRead", default)]
pub consistent_read: Option<bool>,
#[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
pub return_values_on_condition_check_failure: Option<String>,
}
#[derive(Debug, Default, Serialize)]
pub struct BatchExecuteStatementResponse {
#[serde(rename = "Responses")]
pub responses: Vec<BatchStatementResponse>,
#[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
pub consumed_capacity: Option<Vec<crate::types::ConsumedCapacity>>,
}
#[derive(Debug, Default, Serialize)]
#[non_exhaustive]
pub struct BatchStatementResponse {
#[serde(rename = "Error", skip_serializing_if = "Option::is_none")]
pub error: Option<BatchStatementError>,
#[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
pub item: Option<Item>,
#[serde(rename = "TableName", skip_serializing_if = "Option::is_none")]
pub table_name: Option<String>,
}
#[derive(Debug, Default, Serialize)]
pub struct BatchStatementError {
#[serde(rename = "Code")]
pub code: String,
#[serde(rename = "Message")]
pub message: String,
}
pub async fn execute<S: StorageBackend>(
storage: &S,
request: BatchExecuteStatementRequest,
) -> Result<BatchExecuteStatementResponse> {
if let Some(msg) = crate::validation::return_consumed_capacity_rejection(
request.return_consumed_capacity.as_deref(),
) {
return Err(DynoxideError::ValidationException(
crate::validation::envelope_message(&msg),
));
}
if request.statements.is_empty() {
return Err(DynoxideError::ValidationException(
"1 validation error detected: Value '[]' at 'statements' failed to satisfy constraint: Member must have length greater than or equal to 1".to_string(),
));
}
if request.statements.len() > 25 {
return Err(DynoxideError::ValidationException(
"Too many statements in BatchExecuteStatement; limit is 25".to_string(),
));
}
let (prepared, tables) = prepare(storage, &request.statements).await;
let kinds = prepared.iter().filter_map(|p| p.stmt.as_ref().ok());
let (reads, writes): (Vec<_>, Vec<_>) =
kinds.partition(|stmt| matches!(stmt, partiql::parser::Statement::Select { .. }));
if !reads.is_empty() && !writes.is_empty() {
return Err(DynoxideError::ValidationException(
"Read and write requests together in the same batch is not supported.".to_string(),
));
}
let mut seen_targets = HashSet::new();
for target in prepared.iter().filter_map(|p| p.target.as_ref()) {
if !seen_targets.insert(target) {
return Err(DynoxideError::ValidationException(
"Provided list of item keys contains duplicates".to_string(),
));
}
}
let mut responses = Vec::with_capacity(request.statements.len());
let mut records: Vec<WriteCapacity> = Vec::new();
let mut read_units: Vec<(String, usize, bool)> = Vec::new();
let mut failures: Vec<(String, f64)> = Vec::new();
for (stmt_req, prepared) in request.statements.iter().zip(prepared) {
if let Some(msg) = partiql::parser::count_projection_rejection(&stmt_req.statement) {
responses.push(BatchStatementResponse {
error: Some(BatchStatementError {
code: "ValidationError".to_string(),
message: msg,
}),
item: None,
table_name: None,
});
continue;
}
let response = match prepared.stmt {
Err(e) => BatchStatementResponse {
error: Some(BatchStatementError {
code: "ValidationError".to_string(),
message: e.into_message(),
}),
item: None,
table_name: None,
},
Ok(stmt) => {
let table = partiql::parser::table_name(&stmt).map(str::to_string);
let params = stmt_req.parameters.as_deref().unwrap_or_default();
let table_resolved = table.as_deref().is_some_and(|t| tables.contains_key(t));
let unkeyed_read = matches!(stmt, partiql::parser::Statement::Select { .. })
&& table_resolved
&& (prepared.target.is_none() || partiql::parser::index_name(&stmt).is_some());
if unkeyed_read {
responses.push(BatchStatementResponse {
error: Some(BatchStatementError {
code: "ValidationError".to_string(),
message: "Select statements within BatchExecuteStatement must \
specify the primary key in the where clause."
.to_string(),
}),
item: None,
table_name: None,
});
continue;
}
let run = partiql::executor::execute_page(
storage,
&stmt,
params,
None,
None,
stmt_req.consistent_read.unwrap_or(false),
request.return_consumed_capacity.as_deref(),
table.as_deref().and_then(|t| tables.get(t)),
);
let outcome = if matches!(stmt, partiql::parser::Statement::Select { .. }) {
run.await
} else {
super::helpers::with_write_transaction(storage, run).await
};
match outcome {
Ok(page) => {
match page.capacity {
Some(capacity) => records.push(capacity),
None => {
if let Some(ref name) = table {
read_units.push((
name.clone(),
page.size,
stmt_req.consistent_read.unwrap_or(false),
));
}
}
}
BatchStatementResponse {
error: None,
item: page.items.and_then(|items| items.into_iter().next()),
table_name: table,
}
}
Err(e) => {
if let Some(ref name) = table {
let units =
attempted_units(storage, &stmt, params, prepared.target.as_ref())
.await;
failures.push((name.clone(), units));
}
let code = e.short_error_code().to_string();
let echoes_table = code != "ValidationError";
BatchStatementResponse {
error: Some(BatchStatementError {
code,
message: e.to_string(),
}),
item: None,
table_name: if echoes_table { table } else { None },
}
}
}
}
};
responses.push(response);
}
Ok(BatchExecuteStatementResponse {
consumed_capacity: build_capacity(
&records,
&read_units,
&failures,
&request.return_consumed_capacity,
),
responses,
})
}
struct Prepared {
stmt: std::result::Result<partiql::parser::Statement, partiql::parser::ParseError>,
target: Option<(String, String, String)>,
}
async fn prepare<S: StorageBackend>(
storage: &S,
statements: &[BatchStatementRequest],
) -> (Vec<Prepared>, HashMap<String, ResolvedTable>) {
let mut prepared = Vec::with_capacity(statements.len());
let mut tables: HashMap<String, ResolvedTable> = HashMap::new();
for stmt_req in statements {
let parsed = partiql::parser::parse(&stmt_req.statement);
let mut target = None;
if let Ok(ref stmt) = parsed
&& let Some(name) = partiql::parser::table_name(stmt)
{
if !tables.contains_key(name)
&& let Ok(resolved) = ResolvedTable::load(storage, name).await
{
tables.insert(name.to_string(), resolved);
}
if let Some(resolved) = tables.get(name) {
let params = stmt_req.parameters.as_deref().unwrap_or_default();
target = partiql::executor::statement_target_in(stmt, params, resolved);
}
}
prepared.push(Prepared {
stmt: parsed,
target,
});
}
(prepared, tables)
}
async fn attempted_units<S: StorageBackend>(
storage: &S,
stmt: &partiql::parser::Statement,
parameters: &[AttributeValue],
target: Option<&(String, String, String)>,
) -> f64 {
let stored_size = match target {
Some((table, pk, sk)) => storage
.get_item(table, pk, sk)
.await
.ok()
.flatten()
.and_then(|json| serde_json::from_str::<Item>(&json).ok())
.map(|item| crate::types::item_size(&item)),
None => None,
};
let attempted_size = match stmt {
partiql::parser::Statement::Insert { item, .. } => {
let mut resolved = Item::new();
for (name, value) in item {
let av = match value {
partiql::parser::PartiqlValue::Literal(av) => av.clone(),
partiql::parser::PartiqlValue::Parameter(idx) => match parameters.get(*idx) {
Some(av) => av.clone(),
None => continue,
},
};
resolved.insert(name.clone(), av);
}
Some(crate::types::item_size(&resolved))
}
_ => None,
};
crate::types::table_write_capacity_units(stored_size, attempted_size)
}
fn build_capacity(
records: &[WriteCapacity],
read_units: &[(String, usize, bool)],
failures: &[(String, f64)],
mode: &Option<String>,
) -> Option<Vec<crate::types::ConsumedCapacity>> {
if !matches!(mode.as_deref(), Some("TOTAL") | Some("INDEXES")) {
return None;
}
if records.is_empty() && read_units.is_empty() {
return None;
}
let mut by_table = aggregate_by_table(records, 1.0);
for (table, size, consistent) in read_units {
by_table.entry(table.clone()).or_default().table_units +=
crate::types::read_capacity_units_with_consistency(*size, *consistent);
}
let mut entries = per_table_capacity(
&by_table,
mode,
crate::types::consumed_capacity_with_secondary_indexes,
)?;
let mut surcharge: HashMap<&str, f64> = HashMap::new();
for (table, units) in failures {
*surcharge.entry(table.as_str()).or_default() += units;
}
for entry in &mut entries {
entry.capacity_units += surcharge
.get(entry.table_name.as_str())
.copied()
.unwrap_or(0.0);
}
Some(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::Storage;
use crate::types::AttributeValue;
fn s(v: &str) -> AttributeValue {
AttributeValue::S(v.to_string())
}
async fn seed(storage: &Storage) {
let req = serde_json::from_value(serde_json::json!({
"TableName": "pq_btx",
"KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
"AttributeDefinitions": [
{"AttributeName": "pk", "AttributeType": "S"},
{"AttributeName": "g1", "AttributeType": "S"},
{"AttributeName": "g2", "AttributeType": "S"}
],
"BillingMode": "PAY_PER_REQUEST",
"GlobalSecondaryIndexes": [
{
"IndexName": "gsi-one",
"KeySchema": [{"AttributeName": "g1", "KeyType": "HASH"}],
"Projection": {"ProjectionType": "ALL"}
},
{
"IndexName": "gsi-two",
"KeySchema": [{"AttributeName": "g2", "KeyType": "HASH"}],
"Projection": {"ProjectionType": "ALL"}
}
]
}))
.unwrap();
crate::actions::create_table::execute(storage, req)
.await
.unwrap();
for pk in ["a", "b"] {
let put = crate::actions::put_item::PutItemRequest {
table_name: "pq_btx".to_string(),
item: [
("pk".to_string(), s(pk)),
("g1".to_string(), s("x")),
("g2".to_string(), s("y")),
("val".to_string(), s("before")),
]
.into_iter()
.collect(),
..Default::default()
};
crate::actions::put_item::execute(storage, put)
.await
.unwrap();
}
}
async fn val_of(storage: &Storage, pk: &str) -> String {
let req = crate::actions::get_item::GetItemRequest {
table_name: "pq_btx".to_string(),
key: [("pk".to_string(), s(pk))].into_iter().collect(),
..Default::default()
};
match crate::actions::get_item::execute(storage, req)
.await
.unwrap()
.item
.unwrap()
.get("val")
.unwrap()
{
AttributeValue::S(v) => v.clone(),
other => panic!("unexpected val: {other:?}"),
}
}
fn stmt(statement: &str) -> BatchStatementRequest {
BatchStatementRequest {
statement: statement.to_string(),
..Default::default()
}
}
#[tokio::test]
async fn a_failed_statement_rolls_back_without_taking_its_neighbour_with_it() {
let storage = Storage::memory().unwrap();
seed(&storage).await;
storage.fail_gsi_insert_after(3);
let out = execute(
&storage,
BatchExecuteStatementRequest {
statements: vec![
stmt("UPDATE \"pq_btx\" SET val='after' WHERE pk='a'"),
stmt("UPDATE \"pq_btx\" SET val='after' WHERE pk='b'"),
],
..Default::default()
},
)
.await
.unwrap();
assert!(
out.responses[0].error.is_none(),
"the first statement should stand: {:?}",
out.responses[0].error
);
assert_eq!(
val_of(&storage, "a").await,
"after",
"a committed statement must survive its neighbour failing"
);
assert_eq!(
val_of(&storage, "b").await,
"before",
"the failed statement must roll its own base row back"
);
}
}