diff --git a/crates/storage-mongodb/src/condition.rs b/crates/storage-mongodb/src/condition.rs index c2301b94..d3bab9a8 100644 --- a/crates/storage-mongodb/src/condition.rs +++ b/crates/storage-mongodb/src/condition.rs @@ -22,14 +22,17 @@ pub fn condition_to_filter(expr: &Expr, maps: &ExpressionMaps) -> Result Result { let mut parts = vec!["item_data".to_string()]; - for elem in elements { + for (position, elem) in elements.iter().enumerate() { match elem { PathElement::Attribute(name) => { let resolved = if let Some(stripped) = name.strip_prefix('#') { @@ -39,6 +42,11 @@ fn resolve_path_to_field( } else { name.clone() }; + // A member name after the top-level attribute is a key of + // the enclosing map. + if position > 0 { + parts.push("M".to_string()); + } parts.push(resolved); } PathElement::Index(idx) => { @@ -493,267 +501,5 @@ fn regex_escape(s: &str) -> String { // ============================================================================ #[cfg(test)] -mod tests { - use super::*; - use extenddb_core::expression::ExpressionMaps; - use extenddb_core::types::AttributeValue; - use std::collections::HashMap; - - fn make_maps(names: Vec<(&str, &str)>, values: Vec<(&str, AttributeValue)>) -> ExpressionMaps { - let names_map: HashMap = names - .into_iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - let values_map: HashMap = values - .into_iter() - .map(|(k, v)| (k.to_string(), v)) - .collect(); - ExpressionMaps::new(names_map, values_map) - } - - #[test] - fn test_attribute_exists() { - let maps = make_maps(vec![], vec![]); - let expr = Expr::Function { - name: "attribute_exists".to_string(), - args: vec![Expr::Path(vec![PathElement::Attribute("foo".to_string())])], - }; - let filter = condition_to_filter(&expr, &maps).unwrap(); - assert_eq!(filter, doc! { "item_data.foo": { "$exists": true } }); - } - - #[test] - fn test_attribute_not_exists() { - let maps = make_maps(vec![], vec![]); - let expr = Expr::Function { - name: "attribute_not_exists".to_string(), - args: vec![Expr::Path(vec![PathElement::Attribute("bar".to_string())])], - }; - let filter = condition_to_filter(&expr, &maps).unwrap(); - assert_eq!(filter, doc! { "item_data.bar": { "$exists": false } }); - } - - #[test] - fn test_equality_comparison_string() { - let maps = make_maps( - vec![], - vec![(":val", AttributeValue::S("hello".to_string()))], - ); - let expr = Expr::Compare { - left: Box::new(Expr::Path(vec![PathElement::Attribute("name".to_string())])), - op: CompareOp::Eq, - right: Box::new(Expr::Placeholder(":val".to_string())), - }; - let filter = condition_to_filter(&expr, &maps).unwrap(); - assert_eq!(filter, doc! { "item_data.name.S": "hello" }); - } - - #[test] - fn test_less_than_comparison_number() { - let maps = make_maps(vec![], vec![(":min", AttributeValue::N("100".to_string()))]); - let expr = Expr::Compare { - left: Box::new(Expr::Path(vec![PathElement::Attribute( - "price".to_string(), - )])), - op: CompareOp::Lt, - right: Box::new(Expr::Placeholder(":min".to_string())), - }; - let filter = condition_to_filter(&expr, &maps).unwrap(); - assert_eq!(filter, doc! { "item_data.price.N": { "$lt": "100" } }); - } - - #[test] - fn test_and_condition() { - let maps = make_maps( - vec![], - vec![ - (":v1", AttributeValue::S("active".to_string())), - (":v2", AttributeValue::N("5".to_string())), - ], - ); - let expr = Expr::And( - Box::new(Expr::Compare { - left: Box::new(Expr::Path(vec![PathElement::Attribute( - "status".to_string(), - )])), - op: CompareOp::Eq, - right: Box::new(Expr::Placeholder(":v1".to_string())), - }), - Box::new(Expr::Compare { - left: Box::new(Expr::Path(vec![PathElement::Attribute( - "count".to_string(), - )])), - op: CompareOp::Gt, - right: Box::new(Expr::Placeholder(":v2".to_string())), - }), - ); - let filter = condition_to_filter(&expr, &maps).unwrap(); - let expected = doc! { - "$and": [ - { "item_data.status.S": "active" }, - { "item_data.count.N": { "$gt": "5" } } - ] - }; - assert_eq!(filter, expected); - } - - #[test] - fn test_or_condition() { - let maps = make_maps( - vec![], - vec![ - (":a", AttributeValue::S("x".to_string())), - (":b", AttributeValue::S("y".to_string())), - ], - ); - let expr = Expr::Or( - Box::new(Expr::Compare { - left: Box::new(Expr::Path(vec![PathElement::Attribute("f".to_string())])), - op: CompareOp::Eq, - right: Box::new(Expr::Placeholder(":a".to_string())), - }), - Box::new(Expr::Compare { - left: Box::new(Expr::Path(vec![PathElement::Attribute("f".to_string())])), - op: CompareOp::Eq, - right: Box::new(Expr::Placeholder(":b".to_string())), - }), - ); - let filter = condition_to_filter(&expr, &maps).unwrap(); - let expected = doc! { - "$or": [ - { "item_data.f.S": "x" }, - { "item_data.f.S": "y" } - ] - }; - assert_eq!(filter, expected); - } - - #[test] - fn test_not_condition() { - let maps = make_maps(vec![], vec![]); - let expr = Expr::Not(Box::new(Expr::Function { - name: "attribute_exists".to_string(), - args: vec![Expr::Path(vec![PathElement::Attribute( - "deleted".to_string(), - )])], - })); - let filter = condition_to_filter(&expr, &maps).unwrap(); - let expected = doc! { - "$nor": [{ "item_data.deleted": { "$exists": true } }] - }; - assert_eq!(filter, expected); - } - - #[test] - fn test_begins_with() { - let maps = make_maps( - vec![], - vec![(":prefix", AttributeValue::S("user#".to_string()))], - ); - let expr = Expr::Function { - name: "begins_with".to_string(), - args: vec![ - Expr::Path(vec![PathElement::Attribute("sk".to_string())]), - Expr::Placeholder(":prefix".to_string()), - ], - }; - let filter = condition_to_filter(&expr, &maps).unwrap(); - assert_eq!(filter, doc! { "item_data.sk.S": { "$regex": "^user#" } }); - } - - #[test] - fn test_between() { - let maps = make_maps( - vec![], - vec![ - (":lo", AttributeValue::N("10".to_string())), - (":hi", AttributeValue::N("20".to_string())), - ], - ); - let expr = Expr::Between { - operand: Box::new(Expr::Path(vec![PathElement::Attribute("age".to_string())])), - low: Box::new(Expr::Placeholder(":lo".to_string())), - high: Box::new(Expr::Placeholder(":hi".to_string())), - }; - let filter = condition_to_filter(&expr, &maps).unwrap(); - assert_eq!( - filter, - doc! { "item_data.age.N": { "$gte": "10", "$lte": "20" } } - ); - } - - #[test] - fn test_in_condition() { - let maps = make_maps( - vec![], - vec![ - (":v1", AttributeValue::S("a".to_string())), - (":v2", AttributeValue::S("b".to_string())), - (":v3", AttributeValue::S("c".to_string())), - ], - ); - let expr = Expr::In { - operand: Box::new(Expr::Path(vec![PathElement::Attribute("x".to_string())])), - list: vec![ - Expr::Placeholder(":v1".to_string()), - Expr::Placeholder(":v2".to_string()), - Expr::Placeholder(":v3".to_string()), - ], - }; - let filter = condition_to_filter(&expr, &maps).unwrap(); - assert_eq!(filter, doc! { "item_data.x.S": { "$in": ["a", "b", "c"] } }); - } - - #[test] - fn test_name_ref_resolution() { - let maps = make_maps( - vec![("n", "status")], - vec![(":v", AttributeValue::S("active".to_string()))], - ); - let expr = Expr::Compare { - left: Box::new(Expr::Path(vec![PathElement::Attribute("#n".to_string())])), - op: CompareOp::Eq, - right: Box::new(Expr::Placeholder(":v".to_string())), - }; - let filter = condition_to_filter(&expr, &maps).unwrap(); - assert_eq!(filter, doc! { "item_data.status.S": "active" }); - } - - #[test] - fn test_ne_comparison() { - let maps = make_maps( - vec![], - vec![(":v", AttributeValue::S("deleted".to_string()))], - ); - let expr = Expr::Compare { - left: Box::new(Expr::Path(vec![PathElement::Attribute( - "status".to_string(), - )])), - op: CompareOp::Ne, - right: Box::new(Expr::Placeholder(":v".to_string())), - }; - let filter = condition_to_filter(&expr, &maps).unwrap(); - assert_eq!(filter, doc! { "item_data.status.S": { "$ne": "deleted" } }); - } - - #[test] - fn test_regex_escape() { - assert_eq!(regex_escape("user.name"), "user\\.name"); - assert_eq!(regex_escape("a+b"), "a\\+b"); - assert_eq!(regex_escape("normal"), "normal"); - } - - #[test] - fn test_attribute_type() { - let maps = make_maps(vec![], vec![(":t", AttributeValue::S("S".to_string()))]); - let expr = Expr::Function { - name: "attribute_type".to_string(), - args: vec![ - Expr::Path(vec![PathElement::Attribute("field".to_string())]), - Expr::Placeholder(":t".to_string()), - ], - }; - let filter = condition_to_filter(&expr, &maps).unwrap(); - assert_eq!(filter, doc! { "item_data.field.S": { "$exists": true } }); - } -} +#[path = "condition_tests.rs"] +mod tests; diff --git a/crates/storage-mongodb/src/condition_tests.rs b/crates/storage-mongodb/src/condition_tests.rs new file mode 100644 index 00000000..7ea48dac --- /dev/null +++ b/crates/storage-mongodb/src/condition_tests.rs @@ -0,0 +1,328 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +use super::*; +use extenddb_core::expression::ExpressionMaps; +use extenddb_core::types::AttributeValue; +use std::collections::HashMap; + +fn make_maps(names: Vec<(&str, &str)>, values: Vec<(&str, AttributeValue)>) -> ExpressionMaps { + let names_map: HashMap = names + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let values_map: HashMap = values + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(); + ExpressionMaps::new(names_map, values_map) +} + +#[test] +fn test_attribute_exists() { + let maps = make_maps(vec![], vec![]); + let expr = Expr::Function { + name: "attribute_exists".to_string(), + args: vec![Expr::Path(vec![PathElement::Attribute("foo".to_string())])], + }; + let filter = condition_to_filter(&expr, &maps).unwrap(); + assert_eq!(filter, doc! { "item_data.foo": { "$exists": true } }); +} + +#[test] +fn test_attribute_not_exists() { + let maps = make_maps(vec![], vec![]); + let expr = Expr::Function { + name: "attribute_not_exists".to_string(), + args: vec![Expr::Path(vec![PathElement::Attribute("bar".to_string())])], + }; + let filter = condition_to_filter(&expr, &maps).unwrap(); + assert_eq!(filter, doc! { "item_data.bar": { "$exists": false } }); +} + +#[test] +fn test_equality_comparison_string() { + let maps = make_maps( + vec![], + vec![(":val", AttributeValue::S("hello".to_string()))], + ); + let expr = Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute("name".to_string())])), + op: CompareOp::Eq, + right: Box::new(Expr::Placeholder(":val".to_string())), + }; + let filter = condition_to_filter(&expr, &maps).unwrap(); + assert_eq!(filter, doc! { "item_data.name.S": "hello" }); +} + +#[test] +fn test_less_than_comparison_number() { + let maps = make_maps(vec![], vec![(":min", AttributeValue::N("100".to_string()))]); + let expr = Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute( + "price".to_string(), + )])), + op: CompareOp::Lt, + right: Box::new(Expr::Placeholder(":min".to_string())), + }; + let filter = condition_to_filter(&expr, &maps).unwrap(); + assert_eq!(filter, doc! { "item_data.price.N": { "$lt": "100" } }); +} + +#[test] +fn test_and_condition() { + let maps = make_maps( + vec![], + vec![ + (":v1", AttributeValue::S("active".to_string())), + (":v2", AttributeValue::N("5".to_string())), + ], + ); + let expr = Expr::And( + Box::new(Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute( + "status".to_string(), + )])), + op: CompareOp::Eq, + right: Box::new(Expr::Placeholder(":v1".to_string())), + }), + Box::new(Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute( + "count".to_string(), + )])), + op: CompareOp::Gt, + right: Box::new(Expr::Placeholder(":v2".to_string())), + }), + ); + let filter = condition_to_filter(&expr, &maps).unwrap(); + let expected = doc! { + "$and": [ + { "item_data.status.S": "active" }, + { "item_data.count.N": { "$gt": "5" } } + ] + }; + assert_eq!(filter, expected); +} + +#[test] +fn test_or_condition() { + let maps = make_maps( + vec![], + vec![ + (":a", AttributeValue::S("x".to_string())), + (":b", AttributeValue::S("y".to_string())), + ], + ); + let expr = Expr::Or( + Box::new(Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute("f".to_string())])), + op: CompareOp::Eq, + right: Box::new(Expr::Placeholder(":a".to_string())), + }), + Box::new(Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute("f".to_string())])), + op: CompareOp::Eq, + right: Box::new(Expr::Placeholder(":b".to_string())), + }), + ); + let filter = condition_to_filter(&expr, &maps).unwrap(); + let expected = doc! { + "$or": [ + { "item_data.f.S": "x" }, + { "item_data.f.S": "y" } + ] + }; + assert_eq!(filter, expected); +} + +#[test] +fn test_not_condition() { + let maps = make_maps(vec![], vec![]); + let expr = Expr::Not(Box::new(Expr::Function { + name: "attribute_exists".to_string(), + args: vec![Expr::Path(vec![PathElement::Attribute( + "deleted".to_string(), + )])], + })); + let filter = condition_to_filter(&expr, &maps).unwrap(); + let expected = doc! { + "$nor": [{ "item_data.deleted": { "$exists": true } }] + }; + assert_eq!(filter, expected); +} + +#[test] +fn test_begins_with() { + let maps = make_maps( + vec![], + vec![(":prefix", AttributeValue::S("user#".to_string()))], + ); + let expr = Expr::Function { + name: "begins_with".to_string(), + args: vec![ + Expr::Path(vec![PathElement::Attribute("sk".to_string())]), + Expr::Placeholder(":prefix".to_string()), + ], + }; + let filter = condition_to_filter(&expr, &maps).unwrap(); + assert_eq!(filter, doc! { "item_data.sk.S": { "$regex": "^user#" } }); +} + +#[test] +fn test_between() { + let maps = make_maps( + vec![], + vec![ + (":lo", AttributeValue::N("10".to_string())), + (":hi", AttributeValue::N("20".to_string())), + ], + ); + let expr = Expr::Between { + operand: Box::new(Expr::Path(vec![PathElement::Attribute("age".to_string())])), + low: Box::new(Expr::Placeholder(":lo".to_string())), + high: Box::new(Expr::Placeholder(":hi".to_string())), + }; + let filter = condition_to_filter(&expr, &maps).unwrap(); + assert_eq!( + filter, + doc! { "item_data.age.N": { "$gte": "10", "$lte": "20" } } + ); +} + +#[test] +fn test_in_condition() { + let maps = make_maps( + vec![], + vec![ + (":v1", AttributeValue::S("a".to_string())), + (":v2", AttributeValue::S("b".to_string())), + (":v3", AttributeValue::S("c".to_string())), + ], + ); + let expr = Expr::In { + operand: Box::new(Expr::Path(vec![PathElement::Attribute("x".to_string())])), + list: vec![ + Expr::Placeholder(":v1".to_string()), + Expr::Placeholder(":v2".to_string()), + Expr::Placeholder(":v3".to_string()), + ], + }; + let filter = condition_to_filter(&expr, &maps).unwrap(); + assert_eq!(filter, doc! { "item_data.x.S": { "$in": ["a", "b", "c"] } }); +} + +#[test] +fn test_name_ref_resolution() { + let maps = make_maps( + vec![("n", "status")], + vec![(":v", AttributeValue::S("active".to_string()))], + ); + let expr = Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute("#n".to_string())])), + op: CompareOp::Eq, + right: Box::new(Expr::Placeholder(":v".to_string())), + }; + let filter = condition_to_filter(&expr, &maps).unwrap(); + assert_eq!(filter, doc! { "item_data.status.S": "active" }); +} + +#[test] +fn test_ne_comparison() { + let maps = make_maps( + vec![], + vec![(":v", AttributeValue::S("deleted".to_string()))], + ); + let expr = Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute( + "status".to_string(), + )])), + op: CompareOp::Ne, + right: Box::new(Expr::Placeholder(":v".to_string())), + }; + let filter = condition_to_filter(&expr, &maps).unwrap(); + assert_eq!(filter, doc! { "item_data.status.S": { "$ne": "deleted" } }); +} + +#[test] +fn test_regex_escape() { + assert_eq!(regex_escape("user.name"), "user\\.name"); + assert_eq!(regex_escape("a+b"), "a\\+b"); + assert_eq!(regex_escape("normal"), "normal"); +} + +#[test] +fn test_attribute_type() { + let maps = make_maps(vec![], vec![(":t", AttributeValue::S("S".to_string()))]); + let expr = Expr::Function { + name: "attribute_type".to_string(), + args: vec![ + Expr::Path(vec![PathElement::Attribute("field".to_string())]), + Expr::Placeholder(":t".to_string()), + ], + }; + let filter = condition_to_filter(&expr, &maps).unwrap(); + assert_eq!(filter, doc! { "item_data.field.S": { "$exists": true } }); +} + +fn path(elements: &[&str]) -> Expr { + Expr::Path( + elements + .iter() + .map(|e| match e.parse::() { + Ok(i) => PathElement::Index(i), + Err(_) => PathElement::Attribute((*e).to_string()), + }) + .collect(), + ) +} + +fn exists(elements: &[&str]) -> Document { + let maps = make_maps(vec![("m", "m"), ("k", "k")], vec![]); + let expr = Expr::Function { + name: "attribute_exists".to_string(), + args: vec![path(elements)], + }; + condition_to_filter(&expr, &maps).unwrap() +} + +#[test] +fn map_members_and_list_elements_use_the_typed_fields() { + // A map member sits under the map's `M` field and a list element under + // the list's `L` field, as `item_to_document` stores them. + assert_eq!( + exists(&["#m", "#k"]), + doc! { "item_data.m.M.k": { "$exists": true } } + ); + assert_eq!( + exists(&["m", "mm", "k"]), + doc! { "item_data.m.M.mm.M.k": { "$exists": true } } + ); + assert_eq!( + exists(&["l", "0", "k"]), + doc! { "item_data.l.L.0.M.k": { "$exists": true } } + ); + assert_eq!( + exists(&["m", "n", "1"]), + doc! { "item_data.m.M.n.L.1": { "$exists": true } } + ); + assert_eq!( + exists(&["l", "0"]), + doc! { "item_data.l.L.0": { "$exists": true } } + ); + assert_eq!(exists(&["m"]), doc! { "item_data.m": { "$exists": true } }); +} + +#[test] +fn comparison_on_a_map_member_uses_the_typed_field() { + let maps = make_maps( + vec![("m", "m"), ("k", "k")], + vec![(":v", AttributeValue::S("inner".to_string()))], + ); + let expr = Expr::Compare { + left: Box::new(path(&["#m", "#k"])), + op: CompareOp::Eq, + right: Box::new(Expr::Placeholder(":v".to_string())), + }; + let filter = condition_to_filter(&expr, &maps).unwrap(); + assert_eq!(filter, doc! { "item_data.m.M.k.S": "inner" }); +} diff --git a/tests/test_condition_nested_paths.py b/tests/test_condition_nested_paths.py new file mode 100644 index 00000000..682f83f7 --- /dev/null +++ b/tests/test_condition_nested_paths.py @@ -0,0 +1,161 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Conditions on nested map members and list elements, on UpdateItem and DeleteItem. + +The item holds ``m = {k: "inner", n: [10, "b"], mm: {k: "deep"}}`` and +``l = [{k: "first"}, "s"]``. Every expected answer was measured against Amazon +DynamoDB on 2026-10-25 at 20:51 and 20:52 UTC (us-east-1), each request twice: +a holding condition applies the write and a failing one answers +ConditionalCheckFailedException. + +On MongoDB, a condition on a table with no index and no stream is compiled to +a MongoDB filter while the server's cache records that the table has no index. +That entry is written by a session-path write and lasts 60 seconds; each test +puts its own item immediately before the conditional write, and that put +renews the entry when it has expired. + +Dual-target against Amazon DynamoDB and extenddb. +""" + +from __future__ import annotations + +import re +import uuid + +import pytest +from botocore.exceptions import ClientError + +from conftest import scoped_table + +NAMES = {"#m": "m", "#k": "k", "#n": "n", "#mm": "mm", "#l": "l", "#z": "z", "#u": "u"} +VALUES = { + ":x": {"S": "z"}, + ":inner": {"S": "inner"}, + ":deep": {"S": "deep"}, + ":first": {"S": "first"}, + ":b": {"S": "b"}, + ":in": {"S": "in"}, + ":ner": {"S": "ner"}, + ":S": {"S": "S"}, + ":M": {"S": "M"}, + ":other": {"S": "other"}, + ":s": {"S": "s"}, +} + +ITEM = { + "v": {"S": "x"}, + "m": { + "M": { + "k": {"S": "inner"}, + "n": {"L": [{"N": "10"}, {"S": "b"}]}, + "mm": {"M": {"k": {"S": "deep"}}}, + } + }, + "l": {"L": [{"M": {"k": {"S": "first"}}}, {"S": "s"}]}, +} + +HOLDS = [ + "attribute_exists(#m.#k)", + "attribute_not_exists(#m.#z)", + "#m.#k = :inner", + "begins_with(#m.#k, :in)", + "contains(#m.#k, :ner)", + "attribute_type(#m.#k, :S)", + "attribute_type(#m.#mm, :M)", + "#m.#mm.#k = :deep", + "#l[0].#k = :first", + "#m.#n[1] = :b", + "#l[1] = :s", + "attribute_exists(#m.#k) AND #m.#mm.#k = :deep AND #l[0].#k = :first", + "NOT attribute_exists(#m.#mm.#z)", +] + +FAILS = [ + "attribute_exists(#m.#z)", + "attribute_not_exists(#m.#k)", + "#m.#k <> :inner", + "attribute_exists(#l.#k)", + "attribute_exists(#m[0])", + "#m.#k = :other OR #m.#mm.#k = :other OR #l[0].#k = :other", +] + + +def _maps(*exprs: str) -> dict: + tokens = set(re.findall(r"[#:][A-Za-z0-9_]+", " ".join(exprs))) + out = {"ExpressionAttributeNames": {k: v for k, v in NAMES.items() if k in tokens}} + values = {k: v for k, v in VALUES.items() if k in tokens} + if values: + out["ExpressionAttributeValues"] = values + return out + + +@pytest.fixture(scope="module") +def table(dynamodb_client): + with scoped_table( + dynamodb_client, + attribute_definitions=[ + {"AttributeName": "pk", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + ], + key_schema=[ + {"AttributeName": "pk", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + ) as name: + yield name + + +@pytest.fixture +def key(dynamodb_client, table): + key = {"pk": {"S": f"nested-{uuid.uuid4().hex[:8]}"}, "sk": {"S": "item"}} + dynamodb_client.put_item(TableName=table, Item={**key, **ITEM}) + return key + + +def _get(client, table, key): + return client.get_item(TableName=table, Key=key, ConsistentRead=True).get("Item") + + +def _update(client, table, key, condition): + return client.update_item( + TableName=table, + Key=key, + UpdateExpression="SET #u = :x", + ConditionExpression=condition, + **_maps("SET #u = :x", condition), + ) + + +@pytest.mark.parametrize("condition", HOLDS) +def test_update_with_holding_nested_condition_applies(dynamodb_client, table, key, condition): + _update(dynamodb_client, table, key, condition) + assert _get(dynamodb_client, table, key)["u"] == {"S": "z"} + + +@pytest.mark.parametrize("condition", FAILS) +def test_update_with_failing_nested_condition_is_refused(dynamodb_client, table, key, condition): + with pytest.raises(ClientError) as exc_info: + _update(dynamodb_client, table, key, condition) + err = exc_info.value.response["Error"] + assert err["Code"] == "ConditionalCheckFailedException", err + assert "u" not in _get(dynamodb_client, table, key) + + +def test_delete_with_holding_nested_condition_applies(dynamodb_client, table, key): + condition = "#m.#mm.#k = :deep AND #l[0].#k = :first" + dynamodb_client.delete_item( + TableName=table, Key=key, ConditionExpression=condition, **_maps(condition) + ) + assert _get(dynamodb_client, table, key) is None + + +def test_delete_with_failing_nested_condition_is_refused(dynamodb_client, table, key): + condition = "#m.#mm.#k = :other" + with pytest.raises(ClientError) as exc_info: + dynamodb_client.delete_item( + TableName=table, Key=key, ConditionExpression=condition, **_maps(condition) + ) + err = exc_info.value.response["Error"] + assert err["Code"] == "ConditionalCheckFailedException", err + assert _get(dynamodb_client, table, key) is not None