use crate::actions::helpers;
use crate::errors::{DynoxideError, Result};
use crate::storage_backend::StorageBackend;
use crate::types::{self, AttributeValue};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
struct UpdateWorkResult {
old_item: HashMap<String, AttributeValue>,
item: HashMap<String, AttributeValue>,
item_json: String,
size: usize,
old_size: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
struct UpdateItemRequestRaw {
#[serde(rename = "TableName", default)]
table_name: Option<String>,
#[serde(rename = "Key", default)]
key: Option<HashMap<String, AttributeValue>>,
#[serde(rename = "UpdateExpression", default)]
update_expression: Option<String>,
#[serde(rename = "ConditionExpression", default)]
condition_expression: Option<String>,
#[serde(rename = "ExpressionAttributeNames", default)]
expression_attribute_names: Option<HashMap<String, String>>,
#[serde(
rename = "ExpressionAttributeValues",
default,
deserialize_with = "crate::types::deserialize_named_null_refusals"
)]
expression_attribute_values: Option<HashMap<String, AttributeValue>>,
#[serde(rename = "ReturnValues", default)]
return_values: Option<String>,
#[serde(rename = "ReturnConsumedCapacity", default)]
return_consumed_capacity: Option<String>,
#[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
return_values_on_condition_check_failure: Option<String>,
#[serde(rename = "ReturnItemCollectionMetrics", default)]
return_item_collection_metrics: Option<String>,
#[serde(rename = "AttributeUpdates", default)]
attribute_updates: Option<HashMap<String, AttributeValueUpdate>>,
#[serde(rename = "Expected", default)]
expected: Option<serde_json::Value>,
#[serde(rename = "ConditionalOperator", default)]
conditional_operator: Option<String>,
}
#[derive(Debug, Default)]
pub struct UpdateItemRequest {
pub table_name: String,
pub key: HashMap<String, AttributeValue>,
pub update_expression: Option<String>,
pub condition_expression: Option<String>,
pub expression_attribute_names: Option<HashMap<String, String>>,
pub expression_attribute_values: Option<HashMap<String, AttributeValue>>,
pub return_values: Option<String>,
pub return_consumed_capacity: Option<String>,
pub return_values_on_condition_check_failure: Option<String>,
pub return_item_collection_metrics: Option<String>,
pub attribute_updates: Option<HashMap<String, AttributeValueUpdate>>,
pub expected: Option<serde_json::Value>,
pub conditional_operator: Option<String>,
}
fn first_invalid_member_enum(
return_values: Option<&str>,
return_values_on_condition_check_failure: Option<&str>,
return_item_collection_metrics: Option<&str>,
return_consumed_capacity: Option<&str>,
) -> Option<(String, bool)> {
if let Some(rv) = return_values
&& !["ALL_NEW", "UPDATED_OLD", "ALL_OLD", "NONE", "UPDATED_NEW"].contains(&rv)
{
return Some((crate::validation::RETURN_VALUES_REJECTION.to_string(), true));
}
if let Some(msg) = crate::validation::return_values_on_condition_check_failure_rejection(
return_values_on_condition_check_failure,
"returnValuesOnConditionCheckFailure",
) {
return Some((msg, false));
}
if let Some(msg) =
crate::validation::return_item_collection_metrics_rejection(return_item_collection_metrics)
{
return Some((msg, true));
}
if let Some(msg) =
crate::validation::return_consumed_capacity_rejection(return_consumed_capacity)
{
return Some((msg, false));
}
None
}
impl<'de> serde::Deserialize<'de> for UpdateItemRequest {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
let raw = UpdateItemRequestRaw::deserialize(deserializer)?;
use crate::validation::format_validation_errors;
if let Some(message) = crate::types::malformed_condition_boolean(raw.expected.as_ref()) {
return Err(serde::de::Error::custom(message));
}
if raw.key.is_none() && raw.table_name.as_deref() != Some("") {
let mut errors: Vec<String> = Vec::new();
if let Some(msg) = crate::validation::return_consumed_capacity_rejection(
raw.return_consumed_capacity.as_deref(),
) {
errors.push(msg);
}
if let Some(rv) = raw.return_values.as_deref()
&& !["ALL_NEW", "UPDATED_OLD", "ALL_OLD", "NONE", "UPDATED_NEW"].contains(&rv)
{
errors.push(crate::validation::return_values_value_rejection(rv));
}
if let Some(msg) = crate::validation::return_values_on_condition_check_failure_rejection(
raw.return_values_on_condition_check_failure.as_deref(),
"returnValuesOnConditionCheckFailure",
) {
errors.push(msg);
}
if let Some(msg) = crate::validation::return_item_collection_metrics_rejection(
raw.return_item_collection_metrics.as_deref(),
) {
errors.push(msg);
}
errors.push(
"Value null at 'key' failed to satisfy constraint: Member must not be null"
.to_string(),
);
if let Some(msg) = format_validation_errors(&errors) {
return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
}
}
let table_name_errors =
crate::validation::update_item_table_name_errors(raw.table_name.as_deref());
if let Some(msg) = format_validation_errors(&table_name_errors) {
return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
}
let table_name = raw.table_name.unwrap_or_default();
let mut errors = Vec::new();
if raw.key.is_none() {
errors.push(
"Value null at 'key' failed to satisfy constraint: \
Member must not be null"
.to_string(),
);
}
match first_invalid_member_enum(
raw.return_values.as_deref(),
raw.return_values_on_condition_check_failure.as_deref(),
raw.return_item_collection_metrics.as_deref(),
raw.return_consumed_capacity.as_deref(),
) {
Some((msg, true)) => errors.push(msg),
Some((msg, false)) if errors.is_empty() => {
return Err(serde::de::Error::custom(format!("VALIDATION:{msg}")));
}
_ => {}
}
if let Some(msg) = format_validation_errors(&errors) {
return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
}
Ok(UpdateItemRequest {
table_name,
key: raw.key.unwrap_or_default(),
update_expression: raw.update_expression,
condition_expression: raw.condition_expression,
expression_attribute_names: raw.expression_attribute_names,
expression_attribute_values: raw.expression_attribute_values,
return_values: raw.return_values,
return_consumed_capacity: raw.return_consumed_capacity,
return_values_on_condition_check_failure: raw.return_values_on_condition_check_failure,
return_item_collection_metrics: raw.return_item_collection_metrics,
attribute_updates: raw.attribute_updates,
expected: raw.expected,
conditional_operator: raw.conditional_operator,
})
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AttributeValueUpdate {
#[serde(rename = "Action", default = "default_put_action")]
pub action: String,
#[serde(rename = "Value", default)]
pub value: Option<AttributeValue>,
}
const ATTRIBUTE_UPDATE_ACTION_REJECTION: &str =
"Member must satisfy enum value set: [ADD, DELETE, PUT]' for Action";
fn default_put_action() -> String {
"PUT".to_string()
}
#[derive(Debug, Default, Serialize)]
pub struct UpdateItemResponse {
#[serde(rename = "Attributes", skip_serializing_if = "Option::is_none")]
pub attributes: Option<HashMap<String, AttributeValue>>,
#[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
pub consumed_capacity: Option<types::ConsumedCapacity>,
#[serde(
rename = "ItemCollectionMetrics",
skip_serializing_if = "Option::is_none"
)]
pub item_collection_metrics: Option<crate::types::ItemCollectionMetrics>,
}
fn wrap_invalid_update_expression(err: String) -> String {
if err.starts_with("Invalid UpdateExpression:") {
err
} else {
format!("Invalid UpdateExpression: {err}")
}
}
pub async fn execute<S: StorageBackend>(
storage: &S,
request: UpdateItemRequest,
) -> Result<UpdateItemResponse> {
let layer = crate::validation::single_write_layer(request.expected.as_ref());
execute_inner(storage, request, layer)
.await
.map_err(|e| crate::validation::resolve_for_layer(layer, e))
}
async fn execute_inner<S: StorageBackend>(
storage: &S,
mut request: UpdateItemRequest,
layer: crate::validation::ValueLayer,
) -> Result<UpdateItemResponse> {
if let Some(msg) = crate::validation::format_validation_errors(
&crate::validation::update_item_table_name_errors(Some(&request.table_name)),
) {
return Err(DynoxideError::ValidationException(msg));
}
let updates_empty = request.attribute_updates.as_ref().is_some_and(|updates| {
updates.iter().any(|(name, update)| {
name.is_empty()
|| update
.value
.as_ref()
.is_some_and(crate::empty_names::value_has_empty_name)
})
});
crate::empty_names::check_single_item_write(
&request.key,
updates_empty,
request.expected.as_ref(),
request.expression_attribute_names.as_ref(),
request.expression_attribute_values.as_ref(),
None,
)?;
helpers::validate_no_null_false(&request.key)?;
if let Some(ref values) = request.expression_attribute_values {
helpers::validate_no_null_false(values)?;
}
if let Some(ref updates) = request.attribute_updates {
for update in updates.values() {
if let Some(ref value) = update.value {
helpers::validate_no_null_false_value(value)?;
}
}
}
{
let mut non_expr = Vec::new();
let mut expr_params = Vec::new();
if request.attribute_updates.is_some() {
non_expr.push("AttributeUpdates");
}
if request.expected.is_some() {
non_expr.push("Expected");
}
if request.conditional_operator.is_some() {
non_expr.push("ConditionalOperator");
}
if request.update_expression.is_some() {
expr_params.push("UpdateExpression");
}
if request.condition_expression.is_some() {
expr_params.push("ConditionExpression");
}
let no_raw_eav: Option<serde_json::Value> = None;
let ctx = helpers::ExpressionParamContext {
non_expression_params: non_expr,
expression_params: expr_params,
all_expression_param_names: vec![],
expression_attribute_names: &request.expression_attribute_names,
expression_attribute_values: &request.expression_attribute_values,
expression_attribute_values_raw: &no_raw_eav,
layer,
};
helpers::validate_expression_params(&ctx)
.map_err(crate::validation::ClassifiedValidationError::into_tagged)?;
}
if let Some(msg) = crate::validation::conditional_operator_misuse(
request.conditional_operator.as_deref(),
crate::validation::legacy_condition_count(request.expected.as_ref()),
) {
return Err(DynoxideError::EnvelopedValidation(msg.to_string()));
}
crate::value_nesting::check_key_enveloped(&request.key)?;
crate::value_nesting::check_attribute_updates(
request
.attribute_updates
.iter()
.flat_map(|updates| updates.values())
.map(|update| update.value.as_ref()),
)?;
crate::value_nesting::check_expected(request.expected.as_ref())?;
crate::validation::validate_key_values(&request.key, layer)
.map_err(crate::validation::ClassifiedValidationError::into_tagged)?;
if let Some(ref updates) = request.attribute_updates {
let mut names: Vec<&String> = updates.keys().collect();
names.sort();
for name in names {
if let Some(value) = updates[name].value.as_ref()
&& let Some(message) = crate::validation::find_duplicate_set(value, layer)
{
return Err(DynoxideError::EnvelopedValidation(message));
}
}
}
if request.update_expression.is_none()
&& let Some(ref updates) = request.attribute_updates
{
if updates
.values()
.any(|update| !["ADD", "DELETE", "PUT"].contains(&update.action.as_str()))
{
return Err(DynoxideError::EnvelopedValidation(
ATTRIBUTE_UPDATE_ACTION_REJECTION.to_string(),
));
}
for update in updates.values() {
let action = update.action.as_str();
if update.value.is_none() && action != "DELETE" {
return Err(DynoxideError::ValidationException(
"One or more parameter values were invalid: \
Only DELETE action is allowed when no attribute value is specified"
.to_string(),
));
}
if action == "DELETE"
&& let Some(ref val) = update.value
{
let type_name = match val {
AttributeValue::SS(_) | AttributeValue::NS(_) | AttributeValue::BS(_) => None,
_ => Some(val.type_name()),
};
if let Some(tn) = type_name {
return Err(DynoxideError::ValidationException(format!(
"One or more parameter values were invalid: \
DELETE action with value is not supported for the type {tn}"
)));
}
}
if action == "ADD"
&& let Some(ref val) = update.value
{
let allowed = matches!(
val,
AttributeValue::N(_)
| AttributeValue::SS(_)
| AttributeValue::NS(_)
| AttributeValue::BS(_)
| AttributeValue::L(_)
);
if !allowed {
let tn = val.type_name();
return Err(DynoxideError::ValidationException(format!(
"One or more parameter values were invalid: \
ADD action is not supported for the type {tn}"
)));
}
}
}
}
let expected: Option<HashMap<String, helpers::ExpectedCondition>> =
match request.expected.as_ref() {
Some(v) if request.condition_expression.is_none() => helpers::read_expected(v)?,
_ => None,
};
if request.update_expression.is_none()
&& let Some(ref expected) = expected
{
helpers::validate_expected_conditions(expected)
.map_err(crate::validation::tag_for_envelope)?;
}
if let Some(ref ue) = request.update_expression
&& ue.is_empty()
{
return Err(DynoxideError::ValidationException(
"Invalid UpdateExpression: The expression can not be empty;".to_string(),
));
}
if let Some(ref ce) = request.condition_expression
&& ce.is_empty()
{
return Err(DynoxideError::EnvelopedValidation(
"Invalid ConditionExpression: The expression can not be empty;".to_string(),
));
}
if let Some(ref ue) = request.update_expression {
let parsed =
crate::expressions::update::parse(ue).map_err(DynoxideError::EnvelopedValidation)?;
let tracker = crate::expressions::TrackedExpressionAttributes::new(
&request.expression_attribute_names,
&request.expression_attribute_values,
);
crate::expressions::update::track_references(&parsed, &tracker)
.map_err(|e| DynoxideError::EnvelopedValidation(wrap_invalid_update_expression(e)))?;
if let Some(ref ce) = request.condition_expression {
let cond_parsed = crate::expressions::condition::parse(ce).map_err(|e| {
DynoxideError::EnvelopedValidation(format!("Invalid ConditionExpression: {e}"))
})?;
crate::expressions::condition::track_references(&cond_parsed, &tracker).map_err(
|e| DynoxideError::EnvelopedValidation(format!("Invalid ConditionExpression: {e}")),
)?;
}
tracker
.check_unused()
.map_err(crate::validation::tag_for_envelope)?;
}
if let Some(ref ce) = request.condition_expression {
let parsed = crate::expressions::condition::parse(ce).map_err(|e| {
DynoxideError::EnvelopedValidation(format!("Invalid ConditionExpression: {e}"))
})?;
crate::expressions::condition::validate_static(
&parsed,
&request.expression_attribute_values,
)
.map_err(DynoxideError::EnvelopedValidation)?;
crate::expressions::condition::validate_operand_semantics(
&parsed,
&request.expression_attribute_names,
&request.expression_attribute_values,
)
.map_err(|e| {
DynoxideError::EnvelopedValidation(format!("Invalid ConditionExpression: {e}"))
})?;
}
if let Some(ref expected) = expected
&& !expected.is_empty()
{
let converted = helpers::convert_expected_to_condition(
expected,
request.conditional_operator.as_deref(),
)?;
if !converted.expression.is_empty() {
request.condition_expression = Some(converted.expression);
let expr_values = request
.expression_attribute_values
.get_or_insert_with(HashMap::new);
expr_values.extend(converted.attribute_values);
let expr_names = request
.expression_attribute_names
.get_or_insert_with(HashMap::new);
expr_names.extend(converted.attribute_names);
}
}
let meta = helpers::require_table_for_item_op(storage, &request.table_name).await?;
let key_schema = helpers::parse_key_schema(&meta)?;
if let Some(ref rv) = request.return_values {
let rv_upper = rv.to_uppercase();
if !["NONE", "ALL_OLD", "ALL_NEW", "UPDATED_OLD", "UPDATED_NEW"]
.contains(&rv_upper.as_str())
{
return Err(DynoxideError::EnvelopedValidation(
crate::validation::RETURN_VALUES_REJECTION.to_string(),
));
}
}
helpers::validate_key_only(&request.key, &key_schema)?;
let (pk, sk) = helpers::extract_key_strings(&request.key, &key_schema)?;
let legacy_attr_names: Option<Vec<String>> = request
.attribute_updates
.as_ref()
.map(|updates| updates.keys().cloned().collect());
let tracker = crate::expressions::TrackedExpressionAttributes::without_tracking(
&request.expression_attribute_names,
&request.expression_attribute_values,
);
if let Some(ref update_expr) = request.update_expression {
let parsed = crate::expressions::update::parse(update_expr)
.map_err(DynoxideError::ValidationException)?;
let charge = parsed
.request_charge(&tracker)
.map_err(DynoxideError::ValidationException)?;
if charge > types::MAX_ITEM_SIZE {
return Err(DynoxideError::ValidationException(
"Item size to update has exceeded the maximum allowed size".to_string(),
));
}
}
let (
UpdateWorkResult {
old_item,
item,
item_json,
size,
old_size,
},
gsi_units,
lsi_units,
vector_bytes,
) = helpers::with_write_transaction(storage, async {
let existing_json = storage.get_item(&request.table_name, &pk, &sk).await?;
let existing_item: HashMap<String, AttributeValue> = existing_json
.as_ref()
.and_then(|j| serde_json::from_str(j).ok())
.unwrap_or_default();
if let Some(ref cond_expr) = request.condition_expression {
let parsed = crate::expressions::condition::parse(cond_expr)
.map_err(DynoxideError::ValidationException)?;
let result = crate::expressions::condition::evaluate(&parsed, &existing_item, &tracker)
.map_err(DynoxideError::ValidationException)?;
if !result {
let return_item = if request.return_values_on_condition_check_failure.as_deref()
== Some("ALL_OLD")
&& existing_json.is_some()
{
Some(existing_item.clone())
} else {
None
};
return Err(DynoxideError::ConditionalCheckFailedException(
"The conditional request failed".to_string(),
return_item,
));
}
}
let mut item = existing_item;
if existing_json.is_none() {
for (k, v) in &request.key {
item.insert(k.clone(), v.clone());
}
}
let old_item = item.clone();
if let Some(ref update_expr) = request.update_expression {
let parsed = crate::expressions::update::parse(update_expr)
.map_err(DynoxideError::ValidationException)?;
for action in &parsed.set_actions {
validate_not_key_attr(
action.path.first(),
&key_schema,
&request.expression_attribute_names,
)?;
}
for path in &parsed.remove_actions {
validate_not_key_attr(
path.first(),
&key_schema,
&request.expression_attribute_names,
)?;
}
for action in &parsed.add_actions {
validate_not_key_attr(
action.path.first(),
&key_schema,
&request.expression_attribute_names,
)?;
}
for action in &parsed.delete_actions {
validate_not_key_attr(
action.path.first(),
&key_schema,
&request.expression_attribute_names,
)?;
}
crate::expressions::update::apply(&mut item, &parsed, &tracker)
.map_err(DynoxideError::ValidationException)?;
}
if request.update_expression.is_none()
&& let Some(ref updates) = request.attribute_updates
{
apply_attribute_updates(&mut item, updates, &key_schema)?;
}
if request.update_expression.is_some() {
crate::validation::validate_updated_item_nesting(&item)?;
}
crate::validation::validate_item_attribute_values(&item)?;
crate::validation::normalize_item_sets(&mut item);
helpers::validate_updated_index_keys(&old_item, &item, &meta)?;
if types::item_size(&item) > types::MAX_ITEM_SIZE {
return Err(DynoxideError::ValidationException(
"Item size to update has exceeded the maximum allowed size".to_string(),
));
}
let vector_defs = super::vector_index::parse_vector_defs(&meta)?;
let size = types::billed_item_size(&item, &vector_defs);
let item_json = serde_json::to_string(&item)
.map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
let hash_prefix = request
.key
.get(&key_schema.partition_key)
.map(crate::storage::compute_hash_prefix)
.unwrap_or_default();
storage
.put_item_with_hash(
&request.table_name,
&pk,
&sk,
&item_json,
size,
&hash_prefix,
)
.await?;
let prior_image = existing_json.is_some().then_some(&old_item);
let old_size = prior_image.map(|old| types::billed_item_size(old, &vector_defs));
let target = super::gsi::IndexWrite {
table_name: &request.table_name,
pk: &pk,
sk: &sk,
pk_attr: &key_schema.partition_key,
sk_attr: key_schema.sort_key.as_deref(),
old_item: prior_image,
capacity_mode: request.return_consumed_capacity.as_deref(),
};
let gsi_units =
super::gsi::maintain_gsis_after_write(storage, &meta, &target, &item).await?;
let lsi_units =
super::lsi::maintain_lsis_after_write(storage, &meta, &target, &item).await?;
let vector_bytes = super::vector_index::maintain_vector_indexes_after_write(
storage, &meta, &target, &item,
)
.await?;
crate::streams::record_stream_event(storage, &meta, prior_image, Some(&item)).await?;
Ok((
UpdateWorkResult {
old_item,
item,
item_json,
size,
old_size,
},
gsi_units,
lsi_units,
vector_bytes,
))
})
.await?;
let return_values = request.return_values.as_deref().unwrap_or("NONE");
let attributes = match return_values.to_uppercase().as_str() {
"ALL_OLD" => Some(old_item),
"ALL_NEW" => Some(item),
"UPDATED_OLD" => {
if let Some(ref update_expr) = request.update_expression {
let parsed = crate::expressions::update::parse(update_expr)
.map_err(DynoxideError::ValidationException)?;
omit_if_empty(extract_updated_attrs(
&old_item,
&parsed,
&request.expression_attribute_names,
))
} else {
legacy_attr_names
.as_ref()
.map(|names| extract_named_attrs(&old_item, names))
.and_then(omit_if_empty)
}
}
"UPDATED_NEW" => {
if let Some(ref update_expr) = request.update_expression {
let parsed = crate::expressions::update::parse(update_expr)
.map_err(DynoxideError::ValidationException)?;
let new_item: HashMap<String, AttributeValue> = serde_json::from_str(&item_json)
.map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
omit_if_empty(extract_updated_attrs(
&new_item,
&parsed,
&request.expression_attribute_names,
))
} else {
legacy_attr_names
.as_ref()
.map(|names| {
let new_item: HashMap<String, AttributeValue> =
serde_json::from_str(&item_json).unwrap_or_default();
extract_named_attrs(&new_item, names)
})
.and_then(omit_if_empty)
}
}
_ => None, };
let pk_value = request.key.get(&key_schema.partition_key).cloned();
let item_collection_metrics = helpers::build_item_collection_metrics(
storage,
&meta,
&request.table_name,
&pk,
&key_schema.partition_key,
pk_value
.as_ref()
.unwrap_or(&AttributeValue::S(String::new())),
&request.return_item_collection_metrics,
)
.await?;
let consumed_capacity = types::consumed_capacity_with_vector_indexes(
&request.table_name,
types::table_write_capacity_units(old_size, Some(size)),
&gsi_units,
&lsi_units,
&vector_bytes,
&request.return_consumed_capacity,
);
Ok(UpdateItemResponse {
attributes,
consumed_capacity,
item_collection_metrics,
})
}
fn apply_attribute_updates(
item: &mut HashMap<String, AttributeValue>,
updates: &HashMap<String, AttributeValueUpdate>,
key_schema: &helpers::KeySchema,
) -> Result<()> {
for (attr_name, update) in updates {
if attr_name == &key_schema.partition_key
|| key_schema
.sort_key
.as_ref()
.is_some_and(|sk| sk == attr_name)
{
return Err(helpers::key_attribute_update_refusal(attr_name));
}
match update.action.as_str() {
"PUT" => {
if let Some(ref value) = update.value {
item.insert(attr_name.clone(), value.clone());
}
}
"ADD" => {
if let Some(ref add_val) = update.value {
let path = vec![crate::expressions::PathElement::Attribute(
attr_name.clone(),
)];
crate::expressions::update::apply_add_public(item, &path, add_val)
.map_err(DynoxideError::ValidationException)?;
}
}
"DELETE" => {
if let Some(ref del_val) = update.value {
let path = vec![crate::expressions::PathElement::Attribute(
attr_name.clone(),
)];
crate::expressions::update::apply_delete_public(item, &path, del_val)
.map_err(DynoxideError::ValidationException)?;
} else {
item.remove(attr_name);
}
}
_ => {
return Err(DynoxideError::EnvelopedValidation(
ATTRIBUTE_UPDATE_ACTION_REJECTION.to_string(),
));
}
}
}
Ok(())
}
fn extract_updated_attrs(
item: &HashMap<String, AttributeValue>,
expr: &crate::expressions::update::UpdateExpr,
attr_names: &Option<HashMap<String, String>>,
) -> HashMap<String, AttributeValue> {
use crate::expressions::{PathElement, resolve_path, resolve_path_elements};
let no_values: Option<HashMap<String, AttributeValue>> = None;
let tracker =
crate::expressions::TrackedExpressionAttributes::without_tracking(attr_names, &no_values);
let mut paths: Vec<&[PathElement]> = Vec::new();
paths.extend(expr.set_actions.iter().map(|a| a.path.as_slice()));
paths.extend(expr.remove_actions.iter().map(|p| p.as_slice()));
paths.extend(expr.add_actions.iter().map(|a| a.path.as_slice()));
paths.extend(expr.delete_actions.iter().map(|a| a.path.as_slice()));
let mut result = HashMap::new();
for path in paths {
let Ok(resolved) = resolve_path_elements(path, &tracker) else {
continue;
};
if resolved.iter().any(|e| matches!(e, PathElement::Index(_))) {
if let Some(PathElement::Attribute(top)) = resolved.first()
&& let Some(val) = item.get(top)
{
result.insert(top.clone(), val.clone());
}
continue;
}
if let Some(val) = resolve_path(item, &resolved) {
crate::expressions::projection::insert_at_path(&mut result, &resolved, val);
}
}
result
}
fn omit_if_empty(map: HashMap<String, AttributeValue>) -> Option<HashMap<String, AttributeValue>> {
if map.is_empty() { None } else { Some(map) }
}
fn extract_named_attrs(
item: &HashMap<String, AttributeValue>,
attr_names: &[String],
) -> HashMap<String, AttributeValue> {
let mut result = HashMap::new();
for name in attr_names {
if let Some(val) = item.get(name) {
result.insert(name.clone(), val.clone());
}
}
result
}
fn validate_not_key_attr(
first_element: Option<&crate::expressions::PathElement>,
key_schema: &helpers::KeySchema,
expression_attribute_names: &Option<HashMap<String, String>>,
) -> crate::errors::Result<()> {
if let Some(crate::expressions::PathElement::Attribute(name)) = first_element {
let resolved_name = if name.starts_with('#') {
crate::expressions::resolve_name(name, expression_attribute_names)
.map_err(DynoxideError::ValidationException)?
} else {
name.clone()
};
if resolved_name == key_schema.partition_key
|| key_schema
.sort_key
.as_ref()
.is_some_and(|sk| sk == &resolved_name)
{
return Err(helpers::key_attribute_update_refusal(&resolved_name));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::actions::{create_table, put_item, update_item};
use crate::storage::Storage;
use crate::storage_backend::StorageBackend;
#[test]
fn update_item_stops_at_first_invalid_enum() {
let err = serde_json::from_value::<super::UpdateItemRequest>(serde_json::json!({
"TableName": "_conformance_valid_table_name",
"Key": {"pk": {"S": "test"}},
"ReturnValues": "INVALID",
"ReturnConsumedCapacity": "INVALID"
}))
.unwrap_err()
.to_string();
assert!(err.contains("1 validation error detected"), "got: {err}");
assert!(err.contains("enum value set"), "got: {err}");
assert!(
err.contains("[ALL_OLD, UPDATED_OLD, ALL_NEW, UPDATE_NEW, NONE]"),
"got: {err}"
);
assert!(!err.contains("returnConsumedCapacity"), "got: {err}");
}
#[test]
fn update_item_empty_table_name_reports_only_table_name() {
let err = serde_json::from_value::<super::UpdateItemRequest>(serde_json::json!({
"TableName": "",
"Key": {}
}))
.unwrap_err()
.to_string();
assert!(err.contains("1 validation error detected"), "got: {err}");
assert!(err.to_lowercase().contains("tablename"), "got: {err}");
}
#[test]
fn update_item_rolls_back_base_write_when_gsi_fan_out_fails() {
let storage = Storage::memory().unwrap();
let create = serde_json::from_value(serde_json::json!({
"TableName": "Orders",
"KeySchema": [{"AttributeName": "UserId", "KeyType": "HASH"}],
"AttributeDefinitions": [
{"AttributeName": "UserId", "AttributeType": "S"},
{"AttributeName": "Status", "AttributeType": "S"},
{"AttributeName": "Priority", "AttributeType": "S"}
],
"GlobalSecondaryIndexes": [
{"IndexName": "StatusIndex", "KeySchema": [{"AttributeName": "Status", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
{"IndexName": "PriorityIndex", "KeySchema": [{"AttributeName": "Priority", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}}
]
}))
.unwrap();
pollster::block_on(create_table::execute(&storage, create)).unwrap();
let put = serde_json::from_value(serde_json::json!({
"TableName": "Orders",
"Item": {"UserId": {"S": "u1"}, "Status": {"S": "SHIPPED"}, "Priority": {"S": "HIGH"}, "Note": {"S": "before"}}
}))
.unwrap();
pollster::block_on(put_item::execute(&storage, put)).unwrap();
storage.drop_gsi_table("Orders", "PriorityIndex").unwrap();
let update = serde_json::from_value(serde_json::json!({
"TableName": "Orders",
"Key": {"UserId": {"S": "u1"}},
"UpdateExpression": "SET Note = :n",
"ExpressionAttributeValues": {":n": {"S": "after"}}
}))
.unwrap();
let res = pollster::block_on(update_item::execute(&storage, update));
assert!(
res.is_err(),
"a mid-fan-out failure must surface as an error"
);
let rows = pollster::block_on(<Storage as StorageBackend>::scan_items(
&storage,
"Orders",
&Default::default(),
))
.unwrap();
assert_eq!(
rows.len(),
1,
"the item must still be present after rollback"
);
let raw = &rows[0].2;
assert!(
raw.contains("\"before\"") && !raw.contains("\"after\""),
"update must roll back when fan-out fails, leaving the original value: {raw}"
);
}
}