use super::*;
pub(crate) fn evaluate_condition(
condition: &str,
existing: Option<&HashMap<String, AttributeValue>>,
expr_attr_names: &HashMap<String, String>,
expr_attr_values: &HashMap<String, Value>,
) -> Result<(), AwsServiceError> {
evaluate_condition_with_return(condition, existing, expr_attr_names, expr_attr_values, None)
}
pub(crate) fn evaluate_condition_with_return(
condition: &str,
existing: Option<&HashMap<String, AttributeValue>>,
expr_attr_names: &HashMap<String, String>,
expr_attr_values: &HashMap<String, Value>,
return_values_on_failure: Option<&str>,
) -> Result<(), AwsServiceError> {
let empty = HashMap::new();
let item = existing.unwrap_or(&empty);
if evaluate_filter_expression(condition, item, expr_attr_names, expr_attr_values) {
return Ok(());
}
let mut fields = Vec::new();
if return_values_on_failure == Some("ALL_OLD") {
if let Some(existing_item) = existing {
if let Ok(s) = serde_json::to_string(&json!(existing_item)) {
fields.push(("Item".to_string(), s));
}
}
}
Err(AwsServiceError::aws_error_with_fields(
StatusCode::BAD_REQUEST,
"ConditionalCheckFailedException",
"The conditional request failed",
fields,
))
}
pub(crate) fn extract_function_arg<'a>(expr: &'a str, func_name: &str) -> Option<&'a str> {
let with_paren = format!("{func_name}(");
let with_space = format!("{func_name} (");
let rest = expr
.strip_prefix(&with_paren)
.or_else(|| expr.strip_prefix(&with_space))?;
let inner = rest.strip_suffix(')')?;
Some(inner.trim())
}
pub(crate) fn evaluate_key_condition(
expr: &str,
item: &HashMap<String, AttributeValue>,
expr_attr_names: &HashMap<String, String>,
expr_attr_values: &HashMap<String, Value>,
) -> bool {
let trimmed = expr.trim();
let parts = split_on_and(trimmed);
if parts.len() > 1 {
return parts.iter().all(|part| {
evaluate_key_condition(part.trim(), item, expr_attr_names, expr_attr_values)
});
}
let stripped = strip_outer_parens(trimmed);
if stripped != trimmed {
return evaluate_key_condition(stripped, item, expr_attr_names, expr_attr_values);
}
evaluate_single_key_condition(trimmed, item, expr_attr_names, expr_attr_values)
}
pub(crate) fn split_on_top_level_keyword<'a>(expr: &'a str, keyword: &str) -> Vec<&'a str> {
let bytes = expr.as_bytes();
let len = bytes.len();
let kw = keyword.as_bytes();
let is_and = keyword.eq_ignore_ascii_case("AND");
let mut parts: Vec<&str> = Vec::new();
let mut start = 0usize;
let mut depth: i32 = 0;
let mut between_skip: u32 = 0;
let mut i = 0usize;
while i < len {
let ch = bytes[i];
if ch == b'(' {
depth += 1;
i += 1;
continue;
}
if ch == b')' {
if depth > 0 {
depth -= 1;
}
i += 1;
continue;
}
if depth == 0 {
if is_and {
if let Some(end) = match_keyword(bytes, i, b"BETWEEN") {
between_skip = between_skip.saturating_add(1);
i = end;
continue;
}
}
if let Some(end) = match_keyword(bytes, i, kw) {
if is_and && between_skip > 0 {
between_skip -= 1;
i = end;
continue;
}
parts.push(&expr[start..i]);
start = end;
i = end;
continue;
}
}
i += 1;
}
parts.push(&expr[start..]);
parts
}
pub(crate) fn match_keyword(bytes: &[u8], i: usize, keyword: &[u8]) -> Option<usize> {
let end = i + keyword.len();
if end > bytes.len() {
return None;
}
for k in 0..keyword.len() {
if !bytes[i + k].eq_ignore_ascii_case(&keyword[k]) {
return None;
}
}
let needs_word_boundary = keyword.iter().all(|b| b.is_ascii_alphanumeric());
if needs_word_boundary {
let left_ok = i == 0 || bytes[i - 1].is_ascii_whitespace();
if !left_ok {
return None;
}
let right_ok = end == bytes.len() || bytes[end].is_ascii_whitespace();
if !right_ok {
return None;
}
}
Some(end)
}
pub(crate) fn split_on_and(expr: &str) -> Vec<&str> {
split_on_top_level_keyword(expr, "AND")
}
pub(crate) fn split_on_or(expr: &str) -> Vec<&str> {
split_on_top_level_keyword(expr, "OR")
}
pub(crate) fn evaluate_single_key_condition(
part: &str,
item: &HashMap<String, AttributeValue>,
expr_attr_names: &HashMap<String, String>,
expr_attr_values: &HashMap<String, Value>,
) -> bool {
let part = part.trim();
if let Some(rest) = part
.strip_prefix("begins_with(")
.or_else(|| part.strip_prefix("begins_with ("))
{
return key_cond_begins_with(rest, item, expr_attr_names, expr_attr_values);
}
let upper = part.to_ascii_uppercase();
let bytes = upper.as_bytes();
let between_pos = (0..bytes.len().saturating_sub(7)).find(|&i| {
bytes[i..].starts_with(b"BETWEEN")
&& i > 0
&& (bytes[i - 1] as char).is_ascii_whitespace()
&& bytes
.get(i + 7)
.map(|c| (*c as char).is_ascii_whitespace())
.unwrap_or(false)
});
if let Some(between_pos) = between_pos {
return key_cond_between(part, between_pos, item, expr_attr_names, expr_attr_values);
}
key_cond_simple_comparison(part, item, expr_attr_names, expr_attr_values)
}
pub(crate) fn key_cond_begins_with(
rest: &str,
item: &HashMap<String, AttributeValue>,
expr_attr_names: &HashMap<String, String>,
expr_attr_values: &HashMap<String, Value>,
) -> bool {
let Some(inner) = rest.strip_suffix(')') else {
return false;
};
let mut split = inner.splitn(2, ',');
let (Some(attr_ref), Some(val_ref)) = (split.next(), split.next()) else {
return false;
};
let attr_name = resolve_attr_name(attr_ref.trim(), expr_attr_names);
let expected = expr_attr_values.get(val_ref.trim());
let actual = item.get(&attr_name);
match (actual, expected) {
(Some(a), Some(e)) => attribute_begins_with(a, e),
_ => false,
}
}
pub(crate) fn key_cond_between(
part: &str,
between_pos: usize,
item: &HashMap<String, AttributeValue>,
expr_attr_names: &HashMap<String, String>,
expr_attr_values: &HashMap<String, Value>,
) -> bool {
let attr_part = part[..between_pos].trim();
let attr_name = resolve_attr_name(attr_part, expr_attr_names);
let range_part = &part[between_pos + 7..];
let Some(and_pos) = range_part.to_ascii_uppercase().find(" AND ") else {
return false;
};
let lo_ref = range_part[..and_pos].trim();
let hi_ref = range_part[and_pos + 5..].trim();
let lo = expr_attr_values.get(lo_ref);
let hi = expr_attr_values.get(hi_ref);
let actual = item.get(&attr_name);
match (actual, lo, hi) {
(Some(a), Some(l), Some(h)) => {
comparable_types(Some(a), Some(l))
&& comparable_types(Some(a), Some(h))
&& compare_attribute_values(Some(a), Some(l)) != std::cmp::Ordering::Less
&& compare_attribute_values(Some(a), Some(h)) != std::cmp::Ordering::Greater
}
_ => false,
}
}
pub(crate) fn key_cond_simple_comparison(
part: &str,
item: &HashMap<String, AttributeValue>,
expr_attr_names: &HashMap<String, String>,
expr_attr_values: &HashMap<String, Value>,
) -> bool {
for op in &["<=", ">=", "<>", "=", "<", ">"] {
let Some(pos) = part.find(op) else {
continue;
};
let left = part[..pos].trim();
let right = part[pos + op.len()..].trim();
let actual_owned = resolve_path(left, item, expr_attr_names);
let actual = actual_owned.as_ref();
let expected = expr_attr_values.get(right);
if actual.is_none() {
return false;
}
return match *op {
"=" => values_equal(actual, expected),
"<>" => !values_equal(actual, expected),
"<" => {
comparable_types(actual, expected)
&& compare_attribute_values(actual, expected) == std::cmp::Ordering::Less
}
">" => {
comparable_types(actual, expected)
&& compare_attribute_values(actual, expected) == std::cmp::Ordering::Greater
}
"<=" => {
comparable_types(actual, expected) && {
let cmp = compare_attribute_values(actual, expected);
cmp == std::cmp::Ordering::Less || cmp == std::cmp::Ordering::Equal
}
}
">=" => {
comparable_types(actual, expected) && {
let cmp = compare_attribute_values(actual, expected);
cmp == std::cmp::Ordering::Greater || cmp == std::cmp::Ordering::Equal
}
}
_ => false,
};
}
false
}
pub(crate) fn attribute_size(val: &Value) -> Option<usize> {
if let Some(s) = val.get("S").and_then(|v| v.as_str()) {
return Some(s.len());
}
if let Some(b) = val.get("B").and_then(|v| v.as_str()) {
let decoded_len = base64::engine::general_purpose::STANDARD
.decode(b)
.map(|v| v.len())
.unwrap_or(b.len());
return Some(decoded_len);
}
if let Some(arr) = val.get("SS").and_then(|v| v.as_array()) {
return Some(arr.len());
}
if let Some(arr) = val.get("NS").and_then(|v| v.as_array()) {
return Some(arr.len());
}
if let Some(arr) = val.get("BS").and_then(|v| v.as_array()) {
return Some(arr.len());
}
if let Some(arr) = val.get("L").and_then(|v| v.as_array()) {
return Some(arr.len());
}
if let Some(obj) = val.get("M").and_then(|v| v.as_object()) {
return Some(obj.len());
}
None
}
pub(crate) fn evaluate_size_comparison(
part: &str,
item: &HashMap<String, AttributeValue>,
expr_attr_names: &HashMap<String, String>,
expr_attr_values: &HashMap<String, Value>,
) -> Option<bool> {
let open = part.find('(')?;
let close = part[open..].find(')')? + open;
let path = part[open + 1..close].trim();
let remainder = part[close + 1..].trim();
let (op, val_ref) = if let Some(rest) = remainder.strip_prefix("<=") {
("<=", rest.trim())
} else if let Some(rest) = remainder.strip_prefix(">=") {
(">=", rest.trim())
} else if let Some(rest) = remainder.strip_prefix("<>") {
("<>", rest.trim())
} else if let Some(rest) = remainder.strip_prefix('<') {
("<", rest.trim())
} else if let Some(rest) = remainder.strip_prefix('>') {
(">", rest.trim())
} else if let Some(rest) = remainder.strip_prefix('=') {
("=", rest.trim())
} else {
return None;
};
let actual_owned = resolve_path(path, item, expr_attr_names)?;
let size = attribute_size(&actual_owned)? as f64;
let expected = extract_number(&expr_attr_values.get(val_ref).cloned())?;
Some(match op {
"=" => (size - expected).abs() < f64::EPSILON,
"<>" => (size - expected).abs() >= f64::EPSILON,
"<" => size < expected,
">" => size > expected,
"<=" => size <= expected,
">=" => size >= expected,
_ => false,
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum LegacyConditionRole {
Key,
Filter,
}
pub(crate) fn translate_legacy_conditions(
conditions: &serde_json::Map<String, Value>,
role: LegacyConditionRole,
conditional_operator: &str,
names: &mut HashMap<String, String>,
values: &mut HashMap<String, Value>,
) -> Result<String, AwsServiceError> {
let tag = match role {
LegacyConditionRole::Key => "k",
LegacyConditionRole::Filter => "f",
};
let mut entries: Vec<(&String, &Value)> = conditions.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut fragments: Vec<String> = Vec::with_capacity(entries.len());
for (idx, (attr, spec)) in entries.iter().enumerate() {
let operator = spec
.get("ComparisonOperator")
.and_then(|v| v.as_str())
.ok_or_else(|| {
legacy_validation_err(format!(
"ComparisonOperator is required for the condition on attribute {attr}"
))
})?;
let value_list: &[Value] = spec
.get("AttributeValueList")
.and_then(|v| v.as_array())
.map(|a| a.as_slice())
.unwrap_or(&[]);
let name_ph = format!("#leg{tag}{idx}");
names.insert(name_ph.clone(), (*attr).clone());
let mut value_phs: Vec<String> = Vec::with_capacity(value_list.len());
for (vi, v) in value_list.iter().enumerate() {
let ph = format!(":legv{tag}{idx}_{vi}");
values.insert(ph.clone(), v.clone());
value_phs.push(ph);
}
fragments.push(build_legacy_fragment(&name_ph, operator, &value_phs, role)?);
}
let joiner =
if role == LegacyConditionRole::Filter && conditional_operator.eq_ignore_ascii_case("OR") {
" OR "
} else {
" AND "
};
Ok(fragments.join(joiner))
}
pub(crate) fn resolve_write_condition(
body: &Value,
names: &mut HashMap<String, String>,
values: &mut HashMap<String, Value>,
) -> Result<Option<String>, AwsServiceError> {
let expression = body["ConditionExpression"]
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty());
let expected = body["Expected"].as_object().filter(|m| !m.is_empty());
match (expression, expected) {
(Some(_), Some(_)) => Err(AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"ValidationException",
"Can not use both expression and non-expression parameters in the same request: \
Non-expression parameters: {Expected} Expression parameters: {ConditionExpression}",
)),
(Some(expr), None) => Ok(Some(expr.to_string())),
(None, Some(expected)) => {
let op = body["ConditionalOperator"].as_str().unwrap_or("AND");
Ok(Some(translate_legacy_expected(
expected, op, names, values,
)?))
}
(None, None) => Ok(None),
}
}
pub(crate) fn translate_legacy_expected(
expected: &serde_json::Map<String, Value>,
conditional_operator: &str,
names: &mut HashMap<String, String>,
values: &mut HashMap<String, Value>,
) -> Result<String, AwsServiceError> {
let mut entries: Vec<(&String, &Value)> = expected.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut fragments: Vec<String> = Vec::with_capacity(entries.len());
for (idx, (attr, spec)) in entries.iter().enumerate() {
let name_ph = format!("#exp{idx}");
names.insert(name_ph.clone(), (*attr).clone());
if spec.get("ComparisonOperator").is_some() {
let operator = spec
.get("ComparisonOperator")
.and_then(|v| v.as_str())
.unwrap_or("");
let value_list: &[Value] = spec
.get("AttributeValueList")
.and_then(|v| v.as_array())
.map(|a| a.as_slice())
.unwrap_or(&[]);
let mut value_phs: Vec<String> = Vec::with_capacity(value_list.len());
for (vi, v) in value_list.iter().enumerate() {
let ph = format!(":expv{idx}_{vi}");
values.insert(ph.clone(), v.clone());
value_phs.push(ph);
}
fragments.push(build_legacy_fragment(
&name_ph,
operator,
&value_phs,
LegacyConditionRole::Filter,
)?);
continue;
}
let exists = spec.get("Exists").and_then(|v| v.as_bool());
let value = spec.get("Value");
match (exists, value) {
(Some(false), _) => fragments.push(format!("attribute_not_exists({name_ph})")),
(_, Some(v)) => {
let ph = format!(":expv{idx}");
values.insert(ph.clone(), v.clone());
fragments.push(format!("{name_ph} = {ph}"));
}
(Some(true), None) => fragments.push(format!("attribute_exists({name_ph})")),
(None, None) => {
return Err(legacy_validation_err(format!(
"One of Value or Exists must be provided for the Expected condition on attribute {attr}"
)));
}
}
}
let joiner = if conditional_operator.eq_ignore_ascii_case("OR") {
" OR "
} else {
" AND "
};
Ok(fragments.join(joiner))
}
fn legacy_validation_err(message: String) -> AwsServiceError {
AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationException", message)
}
fn build_legacy_fragment(
name_ph: &str,
operator: &str,
values: &[String],
role: LegacyConditionRole,
) -> Result<String, AwsServiceError> {
let key_ok = matches!(
operator,
"EQ" | "LE" | "LT" | "GE" | "GT" | "BEGINS_WITH" | "BETWEEN"
);
if role == LegacyConditionRole::Key && !key_ok {
return Err(legacy_validation_err(format!(
"Unsupported operator on KeyConditions: {operator}"
)));
}
let want = |n: usize| -> Result<(), AwsServiceError> {
if values.len() == n {
Ok(())
} else {
Err(legacy_validation_err(format!(
"ComparisonOperator {operator} requires {n} value(s) in AttributeValueList, got {}",
values.len()
)))
}
};
let frag = match operator {
"EQ" => {
want(1)?;
format!("{name_ph} = {}", values[0])
}
"NE" => {
want(1)?;
format!("{name_ph} <> {}", values[0])
}
"LE" => {
want(1)?;
format!("{name_ph} <= {}", values[0])
}
"LT" => {
want(1)?;
format!("{name_ph} < {}", values[0])
}
"GE" => {
want(1)?;
format!("{name_ph} >= {}", values[0])
}
"GT" => {
want(1)?;
format!("{name_ph} > {}", values[0])
}
"BEGINS_WITH" => {
want(1)?;
format!("begins_with({name_ph}, {})", values[0])
}
"BETWEEN" => {
want(2)?;
format!("{name_ph} BETWEEN {} AND {}", values[0], values[1])
}
"CONTAINS" => {
want(1)?;
format!("contains({name_ph}, {})", values[0])
}
"NOT_CONTAINS" => {
want(1)?;
format!("NOT contains({name_ph}, {})", values[0])
}
"NOT_NULL" => {
want(0)?;
format!("attribute_exists({name_ph})")
}
"NULL" => {
want(0)?;
format!("attribute_not_exists({name_ph})")
}
"IN" => {
if values.is_empty() {
return Err(legacy_validation_err(
"ComparisonOperator IN requires at least one value in AttributeValueList"
.to_string(),
));
}
format!("{name_ph} IN ({})", values.join(", "))
}
other => {
return Err(legacy_validation_err(format!(
"Unsupported ComparisonOperator: {other}"
)));
}
};
Ok(frag)
}
pub(crate) fn evaluate_filter_expression(
expr: &str,
item: &HashMap<String, AttributeValue>,
expr_attr_names: &HashMap<String, String>,
expr_attr_values: &HashMap<String, Value>,
) -> bool {
let trimmed = expr.trim();
let or_parts = split_on_or(trimmed);
if or_parts.len() > 1 {
return or_parts.iter().any(|part| {
evaluate_filter_expression(part.trim(), item, expr_attr_names, expr_attr_values)
});
}
let and_parts = split_on_and(trimmed);
if and_parts.len() > 1 {
return and_parts.iter().all(|part| {
evaluate_filter_expression(part.trim(), item, expr_attr_names, expr_attr_values)
});
}
let stripped = strip_outer_parens(trimmed);
if stripped != trimmed {
return evaluate_filter_expression(stripped, item, expr_attr_names, expr_attr_values);
}
if let Some(rest) = trimmed
.get(..3)
.filter(|p| p.eq_ignore_ascii_case("NOT"))
.map(|_| &trimmed[3..])
.filter(|after| after.starts_with(|c: char| c.is_ascii_whitespace() || c == '('))
{
return !evaluate_filter_expression(rest, item, expr_attr_names, expr_attr_values);
}
evaluate_single_filter_condition(trimmed, item, expr_attr_names, expr_attr_values)
}
#[cfg(test)]
mod legacy_translation_tests {
use super::*;
use serde_json::json;
fn item(pairs: &[(&str, Value)]) -> HashMap<String, AttributeValue> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
fn conditions(v: Value) -> serde_json::Map<String, Value> {
v.as_object().unwrap().clone()
}
#[test]
fn key_conditions_eq_and_begins_with() {
let mut names = HashMap::new();
let mut values = HashMap::new();
let expr = translate_legacy_conditions(
&conditions(json!({
"pk": {"AttributeValueList": [{"S": "a"}], "ComparisonOperator": "EQ"},
"sk": {"AttributeValueList": [{"S": "ord#"}], "ComparisonOperator": "BEGINS_WITH"},
})),
LegacyConditionRole::Key,
"AND",
&mut names,
&mut values,
)
.unwrap();
assert_eq!(
expr,
"#legk0 = :legvk0_0 AND begins_with(#legk1, :legvk1_0)"
);
let matching = item(&[("pk", json!({"S": "a"})), ("sk", json!({"S": "ord#1"}))]);
let wrong_pk = item(&[("pk", json!({"S": "b"})), ("sk", json!({"S": "ord#1"}))]);
let wrong_sk = item(&[("pk", json!({"S": "a"})), ("sk", json!({"S": "x"}))]);
assert!(evaluate_key_condition(&expr, &matching, &names, &values));
assert!(!evaluate_key_condition(&expr, &wrong_pk, &names, &values));
assert!(!evaluate_key_condition(&expr, &wrong_sk, &names, &values));
}
#[test]
fn key_conditions_between_maps_correctly() {
let mut names = HashMap::new();
let mut values = HashMap::new();
let expr = translate_legacy_conditions(
&conditions(json!({
"n": {
"AttributeValueList": [{"N": "10"}, {"N": "20"}],
"ComparisonOperator": "BETWEEN",
},
})),
LegacyConditionRole::Key,
"AND",
&mut names,
&mut values,
)
.unwrap();
assert_eq!(expr, "#legk0 BETWEEN :legvk0_0 AND :legvk0_1");
let inside = item(&[("n", json!({"N": "15"}))]);
let below = item(&[("n", json!({"N": "5"}))]);
let above = item(&[("n", json!({"N": "25"}))]);
assert!(evaluate_key_condition(&expr, &inside, &names, &values));
assert!(!evaluate_key_condition(&expr, &below, &names, &values));
assert!(!evaluate_key_condition(&expr, &above, &names, &values));
}
#[test]
fn filter_operators_ne_contains_not_null() {
let mut names = HashMap::new();
let mut values = HashMap::new();
let expr = translate_legacy_conditions(
&conditions(json!({
"color": {"AttributeValueList": [{"S": "red"}], "ComparisonOperator": "NE"},
"tags": {"AttributeValueList": [{"S": "vip"}], "ComparisonOperator": "CONTAINS"},
"zzz": {"ComparisonOperator": "NOT_NULL"},
})),
LegacyConditionRole::Filter,
"AND",
&mut names,
&mut values,
)
.unwrap();
assert_eq!(
expr,
"#legf0 <> :legvf0_0 AND contains(#legf1, :legvf1_0) AND attribute_exists(#legf2)"
);
let hit = item(&[
("color", json!({"S": "blue"})),
("tags", json!({"SS": ["vip", "x"]})),
("zzz", json!({"S": "present"})),
]);
let miss_color = item(&[
("color", json!({"S": "red"})),
("tags", json!({"SS": ["vip"]})),
("zzz", json!({"S": "present"})),
]);
let miss_zzz = item(&[
("color", json!({"S": "blue"})),
("tags", json!({"SS": ["vip"]})),
]);
assert!(evaluate_filter_expression(&expr, &hit, &names, &values));
assert!(!evaluate_filter_expression(
&expr,
&miss_color,
&names,
&values
));
assert!(!evaluate_filter_expression(
&expr, &miss_zzz, &names, &values
));
}
#[test]
fn unknown_operator_rejected() {
let mut names = HashMap::new();
let mut values = HashMap::new();
let err = translate_legacy_conditions(
&conditions(json!({
"a": {"AttributeValueList": [{"S": "x"}], "ComparisonOperator": "WAT"},
})),
LegacyConditionRole::Filter,
"AND",
&mut names,
&mut values,
)
.unwrap_err();
assert!(format!("{err:?}").contains("Unsupported ComparisonOperator"));
}
#[test]
fn wrong_value_count_rejected() {
let mut names = HashMap::new();
let mut values = HashMap::new();
let err = translate_legacy_conditions(
&conditions(json!({
"n": {"AttributeValueList": [{"N": "10"}], "ComparisonOperator": "BETWEEN"},
})),
LegacyConditionRole::Key,
"AND",
&mut names,
&mut values,
)
.unwrap_err();
assert!(format!("{err:?}").contains("requires 2 value"));
}
#[test]
fn key_role_rejects_filter_only_operator() {
let mut names = HashMap::new();
let mut values = HashMap::new();
let err = translate_legacy_conditions(
&conditions(json!({
"a": {"AttributeValueList": [{"S": "x"}], "ComparisonOperator": "CONTAINS"},
})),
LegacyConditionRole::Key,
"AND",
&mut names,
&mut values,
)
.unwrap_err();
assert!(format!("{err:?}").contains("Unsupported operator on KeyConditions"));
}
}