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)]
#[non_exhaustive]
pub struct BatchStatementRequest {
pub statement: String,
pub parameters: Option<Vec<AttributeValue>>,
pub consistent_read: Option<bool>,
pub return_values_on_condition_check_failure: Option<String>,
pub(crate) parameter_rejection: Option<String>,
}
impl<'de> Deserialize<'de> for BatchStatementRequest {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
#[derive(Deserialize)]
struct Wire {
#[serde(rename = "Statement")]
statement: String,
#[serde(rename = "Parameters", default)]
parameters: Option<Vec<serde_json::Value>>,
#[serde(rename = "ConsistentRead", default)]
consistent_read: Option<bool>,
#[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
return_values_on_condition_check_failure: Option<String>,
}
let wire = Wire::deserialize(deserializer)?;
let mut parameter_rejection = None;
let parameters = match wire.parameters {
None => None,
Some(raw) => {
let mut parameters = Vec::with_capacity(raw.len());
const EMPTY: &str = "Supplied AttributeValue is empty, must contain exactly \
one of the supported datatypes";
for value in raw {
if value.as_object().is_some_and(|fields| fields.is_empty()) {
parameter_rejection.get_or_insert_with(|| EMPTY.to_string());
continue;
}
match crate::types::reading_parameters(|| {
serde_json::from_value::<AttributeValue>(value)
}) {
Ok(parameter) => parameters.push(parameter),
Err(e) => {
let message = e.to_string();
if !message.starts_with(crate::serde_errors::VALIDATION_MARKER)
&& !message
.starts_with(crate::serde_errors::REQUEST_VALIDATION_MARKER)
&& !message.starts_with(EMPTY)
{
return Err(serde::de::Error::custom(message));
}
parameter_rejection.get_or_insert_with(|| {
crate::serde_errors::clean_serde_message(&message).to_string()
});
}
}
}
Some(parameters)
}
};
Ok(Self {
statement: wire.statement,
parameters,
consistent_read: wire.consistent_read,
return_values_on_condition_check_failure: wire.return_values_on_condition_check_failure,
parameter_rejection,
})
}
}
#[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",
serialize_with = "crate::set_order::item_in_hash_order"
)]
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,
#[serde(
rename = "Item",
skip_serializing_if = "Option::is_none",
serialize_with = "crate::set_order::item_in_hash_order"
)]
pub item: Option<Item>,
}
fn statements_echo(statements: &[BatchStatementRequest]) -> 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: &[BatchStatementRequest],
) -> std::fmt::Result {
out.write_str("[")?;
for (i, s) in statements.iter().enumerate() {
if i > 0 {
out.write_str(", ")?;
}
write!(
out,
"BatchStatementRequest(statement={}, parameters=",
s.statement
)?;
match s.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(", consistentRead=")?;
match s.consistent_read {
Some(c) => write!(out, "{c}")?,
None => out.write_str("null")?,
}
out.write_str(", returnValuesOnConditionCheckFailure=")?;
out.write_str(
s.return_values_on_condition_check_failure
.as_deref()
.unwrap_or("null"),
)?;
out.write_str(")")?;
}
out.write_str("]")
}
pub async fn execute<S: StorageBackend>(
storage: &S,
request: BatchExecuteStatementRequest,
) -> Result<BatchExecuteStatementResponse> {
let mut violations = Vec::new();
violations.extend(crate::validation::return_consumed_capacity_rejection(
request.return_consumed_capacity.as_deref(),
));
if request.statements.is_empty() {
violations.push(
"Value '[]' at 'statements' failed to satisfy constraint: Member must have length \
greater than or equal to 1"
.to_string(),
);
}
if request.statements.len() > 25 {
violations.push(format!(
"Value '{}' at 'statements' failed to satisfy constraint: Member must have length \
less than or equal to 25",
statements_echo(&request.statements)
));
}
for (i, statement) in request.statements.iter().enumerate() {
let member = format!("statements.{}.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"),
));
let sent = statement
.parameters
.as_deref()
.filter(|_| statement.parameter_rejection.is_none());
violations.extend(crate::partiql::parameters_violation(
sent,
&format!("{member}.parameters"),
));
}
if let Some(e) = crate::partiql::member_rejection(&violations) {
return Err(e);
}
let (prepared, tables) = prepare(storage, &request.statements).await;
let own_refusals: Vec<Option<String>> = request.statements.iter().map(own_refusal).collect();
let counted = || {
prepared
.iter()
.zip(&own_refusals)
.filter(|(_, refusal)| refusal.is_none())
.map(|(p, _)| p)
};
let kinds = counted().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 counted().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), own_refusal) in
request.statements.iter().zip(prepared).zip(own_refusals)
{
if let Some(message) = own_refusal {
responses.push(BatchStatementResponse {
error: Some(BatchStatementError {
code: "ValidationError".to_string(),
message,
item: None,
}),
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,
}),
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 resolved_table = table.as_deref().and_then(|t| tables.get(t));
let unkeyed = || {
"Select statements within BatchExecuteStatement must specify the \
primary key in the where clause."
.to_string()
};
let refusal = match resolved_table {
Some(resolved) if matches!(stmt, partiql::parser::Statement::Select { .. }) => {
if let Some(index) = partiql::parser::index_name(&stmt) {
Some(
partiql::executor::reject_multi_attribute_index(resolved, index)
.err()
.map_or_else(unkeyed, |e| e.to_string()),
)
} else if let Err(e) =
partiql::executor::reject_overlapping_select(&stmt, params, resolved)
{
Some(e.to_string())
} else {
match partiql::executor::keyed_select(&stmt, params, resolved) {
partiql::executor::KeyedSelect::ByPrimaryKey => None,
partiql::executor::KeyedSelect::NotByPrimaryKey => Some(unkeyed()),
partiql::executor::KeyedSelect::KeyType(message) => Some(message),
}
}
}
_ => None,
};
if let Some(message) = refusal {
responses.push(BatchStatementResponse {
error: Some(BatchStatementError {
code: "ValidationError".to_string(),
message,
item: None,
}),
item: None,
table_name: None,
});
continue;
}
let run = partiql::executor::execute_written_page(
storage,
&stmt_req.statement,
&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)),
stmt_req.return_values_on_condition_check_failure.as_deref() == Some("ALL_OLD"),
);
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) => {
let code = e.short_error_code().to_string();
let message = e.to_string();
let item = match e {
DynoxideError::ConditionalCheckFailedException(_, item) => item,
_ => None,
};
let refused_before_reading = code == "ValidationError"
&& (message == crate::actions::helpers::HASH_KEY_TOO_LARGE
|| message == crate::actions::helpers::RANGE_KEY_TOO_LARGE
|| message
.starts_with(crate::actions::helpers::KEY_ATTRIBUTE_UPDATE));
if let Some(ref name) = table
&& !refused_before_reading
{
let units =
attempted_units(storage, &stmt, params, prepared.target.as_ref())
.await;
failures.push((name.clone(), units));
}
let echoes_table = code != "ValidationError" || refused_before_reading;
BatchStatementResponse {
error: Some(BatchStatementError {
code,
message,
item,
}),
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)>,
}
fn own_refusal(stmt_req: &BatchStatementRequest) -> Option<String> {
if let Some(rejection) = &stmt_req.parameter_rejection {
return Some(rejection.clone());
}
let parameters = stmt_req.parameters.as_deref().unwrap_or_default();
if crate::value_nesting::parameters_nest_too_deep(parameters) {
return Some(crate::value_nesting::deep_parameter_error().to_string());
}
if let Err(e) = parameters
.iter()
.try_for_each(partiql::executor::check_parameter_value)
{
return Some(e.to_string());
}
if let Some(message) = partiql::parser::count_projection_rejection(&stmt_req.statement) {
return Some(message);
}
if crate::empty_names::parameters_have_empty_name(stmt_req.parameters.as_deref()) {
return Some(crate::empty_names::MESSAGE.to_string());
}
None
}
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 supplied = stmt_req.parameters.as_ref().map_or(0, Vec::len);
let parsed = partiql::parser::parse_with_parameters(&stmt_req.statement, supplied).map_err(
|failure| match failure {
partiql::parser::ParseFailure::Statement(e) => e,
partiql::parser::ParseFailure::Literal(message) => {
partiql::parser::ParseError::Validation(message)
}
},
);
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"
);
}
}