graphddb-runtime 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/PKG-INFO +1 -1
  2. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/filters.py +5 -3
  3. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/runtime.py +147 -0
  4. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/transactions.py +84 -0
  5. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime.egg-info/PKG-INFO +1 -1
  6. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/pyproject.toml +1 -1
  7. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_unit.py +187 -0
  8. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/README.md +0 -0
  9. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/__init__.py +0 -0
  10. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/async_runtime.py +0 -0
  11. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/batch.py +0 -0
  12. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/concurrency.py +0 -0
  13. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/cursor.py +0 -0
  14. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/errors.py +0 -0
  15. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/hydration.py +0 -0
  16. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/limits.py +0 -0
  17. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/per_key_cursor.py +0 -0
  18. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/relations.py +0 -0
  19. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/templates.py +0 -0
  20. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime.egg-info/SOURCES.txt +0 -0
  21. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
  22. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime.egg-info/requires.txt +0 -0
  23. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime.egg-info/top_level.txt +0 -0
  24. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/setup.cfg +0 -0
  25. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_concurrency.py +0 -0
  26. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_contract_runtime.py +0 -0
  27. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration.py +0 -0
  28. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_command.py +0 -0
  29. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_compose.py +0 -0
  30. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_contract.py +0 -0
  31. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_edge_derive.py +0 -0
  32. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_edge_write.py +0 -0
  33. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_events.py +0 -0
  34. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_referential.py +0 -0
  35. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_relations.py +0 -0
  36. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_unique.py +0 -0
  37. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_relations.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -2,9 +2,11 @@
2
2
 
3
3
  Port of the TypeScript ``compileFilterExpression``
4
4
  (``src/expression/filter-expression.ts``). The declarative tree carried in a
5
- query spec's ``filter.declarative`` is JSON-safe (the TS bridge guard rejects
6
- the non-serializable ``cond`` escape hatch), so only the operator/logical forms
7
- are handled here.
5
+ query spec's ``filter.declarative`` is JSON-safe, so only the operator/logical
6
+ forms are handled here. (A read-side ``cond`` escape hatch is not serialized into
7
+ ``filter.declarative``; a *write*-side ``cond`` rides the separate ``raw``
8
+ condition spec — issue #114-B — rendered in ``runtime.py`` / ``transactions.py``,
9
+ not here.)
8
10
 
9
11
  Names are ``#``-aliased columns (reused per distinct column); values are
10
12
  ``:``-aliased parameters — no literal interpolation. The result is returned in
@@ -64,6 +64,53 @@ def _normalize_template(template: Optional[str]) -> Optional[str]:
64
64
  return _PLACEHOLDER_RE.sub("\x00", template)
65
65
 
66
66
 
67
+ def _resolve_condition_leaf(value: Any, params: Mapping[str, Any]) -> Any:
68
+ """Resolve one declarative-condition operand leaf (issue #114-A).
69
+
70
+ A ``{"$param": name}`` marker is replaced by the **typed** caller-param value
71
+ (so a numeric comparison stays numeric); a native literal is returned as-is.
72
+ """
73
+ if isinstance(value, dict) and isinstance(value.get("$param"), str):
74
+ name = value["$param"]
75
+ if name not in params or params[name] is None:
76
+ raise KeyError(f"condition references unbound parameter '{name}'")
77
+ return params[name]
78
+ return value
79
+
80
+
81
+ def _resolve_condition_tree(node: Any, params: Mapping[str, Any]) -> Any:
82
+ """Resolve every ``{"$param"}`` marker in a serialized condition tree.
83
+
84
+ Walks the JSON-safe operator tree the TS planner emits (field operator
85
+ objects + ``and`` / ``or`` / ``not`` groups), substituting param markers with
86
+ typed caller-param values, and returns a tree shaped exactly like a
87
+ declarative ``filter`` so :func:`compile_filter` compiles it identically.
88
+ """
89
+ out: Dict[str, Any] = {}
90
+ for key, value in node.items():
91
+ if key in ("and", "or"):
92
+ out[key] = [_resolve_condition_tree(s, params) for s in value]
93
+ elif key == "not":
94
+ out[key] = _resolve_condition_tree(value, params)
95
+ else:
96
+ ops: Dict[str, Any] = {}
97
+ for op, op_val in value.items():
98
+ if op == "between":
99
+ lo, hi = op_val
100
+ ops[op] = [
101
+ _resolve_condition_leaf(lo, params),
102
+ _resolve_condition_leaf(hi, params),
103
+ ]
104
+ elif op == "in":
105
+ ops[op] = [_resolve_condition_leaf(v, params) for v in op_val]
106
+ elif op == "attributeExists":
107
+ ops[op] = op_val
108
+ else:
109
+ ops[op] = _resolve_condition_leaf(op_val, params)
110
+ out[key] = ops
111
+ return out
112
+
113
+
67
114
  def _present(records: Any) -> List[Dict[str, Any]]:
68
115
  """The present (non-``None``, dict) parent records from a result collection.
69
116
 
@@ -1602,6 +1649,17 @@ class GraphDDBRuntime:
1602
1649
  values[v] = self._serializer.serialize(resolve_template(tmpl, params))
1603
1650
  sets.append(f"{n} = {v}")
1604
1651
 
1652
+ # GSI key re-derivation (issue #115): an UpdateItem that changes a GSI's
1653
+ # composing field MUST re-derive its `<index>PK`/`<index>SK` or the index
1654
+ # silently rots — exactly as the TS `buildUpdateInput` does. The available
1655
+ # values are the caller `params` (key fields ∪ change fields); a GSI is
1656
+ # affected when at least one of its template placeholders is a changed
1657
+ # field. An affected GSI whose composing fields are not all available
1658
+ # throws (mirroring the base-table partial-key throw).
1659
+ self._append_gsi_rederive_sets(
1660
+ command_id, spec, params, names, values, sets
1661
+ )
1662
+
1605
1663
  request: Dict[str, Any] = {
1606
1664
  "TableName": self._physical_table(spec["tableName"]),
1607
1665
  "Key": key,
@@ -1613,6 +1671,66 @@ class GraphDDBRuntime:
1613
1671
  self._apply_condition(spec, request, params)
1614
1672
  self._call(command_id, self._client.update_item, request)
1615
1673
 
1674
+ def _append_gsi_rederive_sets(
1675
+ self,
1676
+ command_id: str,
1677
+ spec: Mapping[str, Any],
1678
+ params: Mapping[str, Any],
1679
+ names: Dict[str, str],
1680
+ values: Dict[str, Any],
1681
+ sets: List[str],
1682
+ ) -> None:
1683
+ """Re-derive every affected GSI key into the UpdateExpression (issue #115).
1684
+
1685
+ Mirrors the TS ``buildUpdateInput``: a GSI is *affected* when at least one
1686
+ of its ``inputFields`` is a changed field; an affected GSI re-derives its
1687
+ ``<index>PK``/``<index>SK`` from the available caller params (key fields ∪
1688
+ change fields). An affected GSI whose ``inputFields`` are not all available
1689
+ throws — the SAME explicit error the TS path raises — rather than letting
1690
+ the index silently rot.
1691
+ """
1692
+ entity_meta = self._entities.get(spec.get("entity"), {})
1693
+ gsis = entity_meta.get("gsis", [])
1694
+ if not gsis:
1695
+ return
1696
+ changed = set(spec.get("changes", {}).keys())
1697
+ if not changed:
1698
+ return
1699
+ # Available value map: caller params with a non-None value.
1700
+ available = {k: v for k, v in params.items() if v is not None}
1701
+ gsi_index = 0
1702
+ for gsi in gsis:
1703
+ input_fields = list(gsi.get("inputFields", []))
1704
+ if not any(f in changed for f in input_fields):
1705
+ continue # unaffected GSI — never touched (non-regression).
1706
+ missing = [f for f in input_fields if f not in available]
1707
+ if missing:
1708
+ changed_in_gsi = [f for f in input_fields if f in changed]
1709
+ raise GraphDDBError(
1710
+ f"{command_id}: updating "
1711
+ f"{', '.join(repr(f) for f in changed_in_gsi)} affects index "
1712
+ f"'{gsi['indexName']}' (also depends on "
1713
+ f"{', '.join(repr(f) for f in missing)}); provide "
1714
+ f"{'them' if len(missing) > 1 else 'it'}, or use "
1715
+ f"read-modify-write."
1716
+ )
1717
+ index = gsi["indexName"]
1718
+ pk_tmpl = gsi.get("pkTemplate")
1719
+ sk_tmpl = gsi.get("skTemplate")
1720
+ if pk_tmpl is not None:
1721
+ n = f"#gsi{gsi_index}pk"
1722
+ v = f":gsi{gsi_index}pk"
1723
+ names[n] = f"{index}PK"
1724
+ values[v] = self._serializer.serialize(self._fill(pk_tmpl, available))
1725
+ sets.append(f"{n} = {v}")
1726
+ if sk_tmpl is not None:
1727
+ n = f"#gsi{gsi_index}sk"
1728
+ v = f":gsi{gsi_index}sk"
1729
+ names[n] = f"{index}SK"
1730
+ values[v] = self._serializer.serialize(self._fill(sk_tmpl, available))
1731
+ sets.append(f"{n} = {v}")
1732
+ gsi_index += 1
1733
+
1616
1734
  def _run_delete_item(
1617
1735
  self, command_id: str, spec: Mapping[str, Any], params: Mapping[str, Any]
1618
1736
  ) -> None:
@@ -1668,6 +1786,35 @@ class GraphDDBRuntime:
1668
1786
  values[v] = self._serializer.serialize(resolved)
1669
1787
  clauses.append(f"{n} = {v}")
1670
1788
  request["ConditionExpression"] = " AND ".join(clauses)
1789
+ elif kind == "expr":
1790
+ # The declarative operator tree (issue #114-A): resolve each
1791
+ # ``{"$param": name}`` marker to its typed caller-param value, then
1792
+ # compile the tree to a ConditionExpression with the SAME mechanics as
1793
+ # the read-side filter (``compile_filter``) so TS and Python emit an
1794
+ # identical expression / semantics. ``compile_filter`` allocates
1795
+ # ``#f`` / ``:vf`` aliases that never collide with the update ``#c`` /
1796
+ # ``:c`` namespace, so the compiled names/values merge cleanly.
1797
+ concrete = _resolve_condition_tree(condition["declarative"], params)
1798
+ compiled = compile_filter(concrete, self._serializer)
1799
+ if compiled is None:
1800
+ return
1801
+ names.update(compiled["ExpressionAttributeNames"])
1802
+ values = request.setdefault("ExpressionAttributeValues", {})
1803
+ values.update(compiled["ExpressionAttributeValues"])
1804
+ request["ConditionExpression"] = compiled["FilterExpression"]
1805
+ elif kind == "raw":
1806
+ # A raw ``cond`` write condition (issue #114-B): the expression and
1807
+ # the ``#cr_*`` name aliases are finished; bind each ``:crN`` value to
1808
+ # its literal, or — when it is a ``{"$param": name}`` marker — to the
1809
+ # typed caller-param value, then serialize. ``#cr_*`` / ``:cr*`` never
1810
+ # collide with the update namespace, so they merge cleanly. The
1811
+ # expression mirrors the TS ``serializeRawCondition`` output verbatim.
1812
+ names.update(condition["names"])
1813
+ values = request.setdefault("ExpressionAttributeValues", {})
1814
+ for alias, raw_val in condition["values"].items():
1815
+ resolved = _resolve_condition_leaf(raw_val, params)
1816
+ values[alias] = self._serializer.serialize(resolved)
1817
+ request["ConditionExpression"] = condition["expression"]
1671
1818
 
1672
1819
  def _add_key_attributes(
1673
1820
  self, entity_name: str, item: Dict[str, Any], params: Mapping[str, Any]
@@ -18,6 +18,8 @@ from __future__ import annotations
18
18
  import re
19
19
  from typing import Any, Dict, List, Mapping, Optional
20
20
 
21
+ from .filters import compile_filter
22
+
21
23
  _PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
22
24
  _WHOLE_PLACEHOLDER_RE = re.compile(r"^\{[^{}]+\}$")
23
25
 
@@ -64,6 +66,59 @@ def _resolve_value(
64
66
  return _resolve_template(template, params, element)
65
67
 
66
68
 
69
+ def _resolve_condition_leaf(
70
+ value: Any,
71
+ params: Mapping[str, Any],
72
+ element: Optional[Mapping[str, Any]],
73
+ ) -> Any:
74
+ """Resolve one declarative-condition operand leaf in a transaction (#114-A).
75
+
76
+ A ``{"$param": name}`` marker binds to the typed param value, or — when the
77
+ name is ``item.<field>`` — to the current ``forEach`` element's field; a
78
+ native literal is returned as-is.
79
+ """
80
+ if isinstance(value, dict) and isinstance(value.get("$param"), str):
81
+ return _resolve_value("{" + value["$param"] + "}", params, element)
82
+ return value
83
+
84
+
85
+ def _resolve_condition_tree(
86
+ node: Any,
87
+ params: Mapping[str, Any],
88
+ element: Optional[Mapping[str, Any]],
89
+ ) -> Any:
90
+ """Resolve every ``{"$param"}`` marker in a serialized condition tree (#114-A).
91
+
92
+ Mirrors the runtime's resolver but element-aware, producing a tree shaped
93
+ like a declarative ``filter`` for :func:`compile_filter`.
94
+ """
95
+ out: Dict[str, Any] = {}
96
+ for key, value in node.items():
97
+ if key in ("and", "or"):
98
+ out[key] = [_resolve_condition_tree(s, params, element) for s in value]
99
+ elif key == "not":
100
+ out[key] = _resolve_condition_tree(value, params, element)
101
+ else:
102
+ ops: Dict[str, Any] = {}
103
+ for op, op_val in value.items():
104
+ if op == "between":
105
+ lo, hi = op_val
106
+ ops[op] = [
107
+ _resolve_condition_leaf(lo, params, element),
108
+ _resolve_condition_leaf(hi, params, element),
109
+ ]
110
+ elif op == "in":
111
+ ops[op] = [
112
+ _resolve_condition_leaf(v, params, element) for v in op_val
113
+ ]
114
+ elif op == "attributeExists":
115
+ ops[op] = op_val
116
+ else:
117
+ ops[op] = _resolve_condition_leaf(op_val, params, element)
118
+ out[key] = ops
119
+ return out
120
+
121
+
67
122
  def _resolve_record(
68
123
  record: Optional[Mapping[str, str]],
69
124
  params: Mapping[str, Any],
@@ -318,6 +373,35 @@ class TransactionExpander:
318
373
  target["ConditionExpression"] = f"{fn}(#ce)"
319
374
  return
320
375
  values = target.setdefault("ExpressionAttributeValues", {})
376
+ if kind == "expr":
377
+ # Declarative operator tree (issue #114-A): resolve each
378
+ # ``{"$param": name}`` marker against the tx params / forEach element
379
+ # (a ``{item.field}`` name binds from the element), then compile with
380
+ # the SAME ``compile_filter`` mechanics as the read-side filter. The
381
+ # compiled ``#f`` / ``:vf`` aliases never collide with the update
382
+ # ``#u`` namespace, so they merge cleanly.
383
+ concrete = _resolve_condition_tree(
384
+ condition["declarative"], params, element
385
+ )
386
+ compiled = compile_filter(concrete, self._serializer)
387
+ if compiled is None:
388
+ return
389
+ names.update(compiled["ExpressionAttributeNames"])
390
+ values.update(compiled["ExpressionAttributeValues"])
391
+ target["ConditionExpression"] = compiled["FilterExpression"]
392
+ return
393
+ if kind == "raw":
394
+ # A raw ``cond`` write condition (issue #114-B): the expression and
395
+ # ``#cr_*`` aliases are finished; bind each ``:crN`` value to its
396
+ # literal or — when a ``{"$param": name}`` marker — against the tx
397
+ # params / forEach element (a ``{item.field}`` name binds from the
398
+ # element). ``#cr_*`` / ``:cr*`` never collide with the update aliases.
399
+ names.update(condition["names"])
400
+ for alias, raw_val in condition["values"].items():
401
+ resolved = _resolve_condition_leaf(raw_val, params, element)
402
+ values[alias] = self._serializer.serialize(resolved)
403
+ target["ConditionExpression"] = condition["expression"]
404
+ return
321
405
  clauses = []
322
406
  for i, (field, tmpl) in enumerate(condition["fields"].items()):
323
407
  n = f"#e{i}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphddb-runtime"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -231,6 +231,54 @@ def test_update_item_builds_set_expression():
231
231
  assert plain(req["ExpressionAttributeValues"]) == {":c0": "disabled"}
232
232
 
233
233
 
234
+ def test_update_item_rederives_affected_gsi_key():
235
+ # #115: setOrderStatus changes `status` (a GSI1 composing field). With
236
+ # `tenantId` also supplied, GSI1's composing fields {tenantId, status, orderId}
237
+ # are all available, so the update re-derives GSI1PK/GSI1SK in the SAME
238
+ # UpdateExpression — the index follows the row instead of rotting.
239
+ client = FakeClient()
240
+ rt = make_runtime(client)
241
+ rt.execute_command(
242
+ "setOrderStatus",
243
+ {"orderId": "o1", "tenantId": "t1", "status": "SHIPPED"},
244
+ )
245
+ method, req = client.calls[0]
246
+ assert method == "update_item"
247
+ names = req["ExpressionAttributeNames"]
248
+ values = plain(req["ExpressionAttributeValues"])
249
+ # The status SET plus the re-derived GSI key attributes.
250
+ assert "status" in names.values()
251
+ gsi_pk_alias = next(a for a, n in names.items() if n == "GSI1PK")
252
+ gsi_sk_alias = next(a for a, n in names.items() if n == "GSI1SK")
253
+ assert f"{gsi_pk_alias} = " in req["UpdateExpression"]
254
+ pk_val_alias = req["UpdateExpression"].split(f"{gsi_pk_alias} = ")[1].split()[0].rstrip(",")
255
+ sk_val_alias = req["UpdateExpression"].split(f"{gsi_sk_alias} = ")[1].split()[0].rstrip(",")
256
+ assert values[pk_val_alias] == "TENANT#t1#STATUS#SHIPPED"
257
+ assert values[sk_val_alias] == "ORDER#o1"
258
+
259
+
260
+ def test_update_item_throws_when_gsi_field_unavailable():
261
+ # #115: status alone affects GSI1 but tenantId is missing → explicit throw.
262
+ from graphddb_runtime.errors import GraphDDBError
263
+
264
+ client = FakeClient()
265
+ rt = make_runtime(client)
266
+ with pytest.raises(GraphDDBError, match=r"affects index 'GSI1'.*depends on 'tenantId'"):
267
+ rt.execute_command("setOrderStatusBad", {"orderId": "o1", "status": "SHIPPED"})
268
+ assert client.calls == [] # nothing written
269
+
270
+
271
+ def test_update_item_does_not_touch_unaffected_gsi():
272
+ # #115 non-regression: disableUser changes `status` on a User whose GSI1 is
273
+ # keyed by `email` (status is NOT a composing field), so NO GSI re-derivation.
274
+ client = FakeClient()
275
+ rt = make_runtime(client)
276
+ rt.execute_command("disableUser", {"userId": "alice", "status": "disabled"})
277
+ _method, req = client.calls[0]
278
+ assert "GSI1PK" not in req["ExpressionAttributeNames"].values()
279
+ assert req["UpdateExpression"] == "SET #c0 = :c0"
280
+
281
+
234
282
  def test_put_item_injects_key_attributes():
235
283
  client = FakeClient()
236
284
  rt = make_runtime(client)
@@ -342,6 +390,145 @@ def test_conditional_put_emits_attribute_not_exists_on_named_field():
342
390
  assert req["ExpressionAttributeNames"]["#ce"] == "PK"
343
391
 
344
392
 
393
+ def test_conditional_expr_tree_renders_via_compile_filter():
394
+ """Issue #114-A: a declarative operator tree (`{ kind: expr, declarative }`)
395
+ resolves its `{$param}` markers (typed) and compiles to a ConditionExpression
396
+ with the read-side filter mechanics (`#f`/`:vf` aliases).
397
+ """
398
+ client = FakeClient()
399
+ rt = make_runtime(client)
400
+ rt._commands["addGroupMember"]["params"] = {
401
+ **rt._commands["addGroupMember"]["params"],
402
+ "role_0": {"type": "string", "required": True},
403
+ "role_1": {"type": "string", "required": True},
404
+ }
405
+ rt._commands["addGroupMember"]["condition"] = {
406
+ "kind": "expr",
407
+ "declarative": {
408
+ "and": [
409
+ {"role": {"in": [{"$param": "role_0"}, {"$param": "role_1"}]}},
410
+ {"role": {"beginsWith": {"$param": "role"}}},
411
+ ]
412
+ },
413
+ }
414
+ rt.execute_command(
415
+ "addGroupMember",
416
+ {
417
+ "groupId": "eng",
418
+ "userId": "alice",
419
+ "role": "admin",
420
+ "role_0": "admin",
421
+ "role_1": "owner",
422
+ },
423
+ )
424
+ _method, req = client.calls[0]
425
+ assert req["ConditionExpression"] == (
426
+ "(#f0 IN (:vf0, :vf1) AND begins_with(#f0, :vf2))"
427
+ )
428
+ assert req["ExpressionAttributeNames"]["#f0"] == "role"
429
+ values = plain(req["ExpressionAttributeValues"])
430
+ assert values[":vf0"] == "admin"
431
+ assert values[":vf1"] == "owner"
432
+ assert values[":vf2"] == "admin"
433
+
434
+
435
+ def test_conditional_expr_numeric_comparison_keeps_type():
436
+ """A numeric `{$param}` operand stays numeric (serialized as N), so `gt`
437
+ compares numerically rather than against a stringified value.
438
+ """
439
+ client = FakeClient()
440
+ rt = make_runtime(client)
441
+ rt._commands["disableUser"]["params"] = {
442
+ **rt._commands["disableUser"]["params"],
443
+ "minVersion": {"type": "number", "required": True},
444
+ }
445
+ rt._commands["disableUser"]["condition"] = {
446
+ "kind": "expr",
447
+ "declarative": {"version": {"gt": {"$param": "minVersion"}}},
448
+ }
449
+ rt.execute_command(
450
+ "disableUser", {"userId": "alice", "status": "disabled", "minVersion": 3}
451
+ )
452
+ _method, req = client.calls[0]
453
+ assert req["ConditionExpression"] == "#f0 > :vf0"
454
+ assert req["ExpressionAttributeValues"][":vf0"] == {"N": "3"}
455
+
456
+
457
+ def test_conditional_expr_size_renders_size_function():
458
+ """Audit fix (issue #114-A): the `size` operator must compile to
459
+ `size(#f) = :v`, not be silently dropped. The numeric length operand stays
460
+ N-typed.
461
+ """
462
+ client = FakeClient()
463
+ rt = make_runtime(client)
464
+ rt._commands["addGroupMember"]["params"] = {
465
+ **rt._commands["addGroupMember"]["params"],
466
+ "role_size": {"type": "number", "required": True},
467
+ }
468
+ rt._commands["addGroupMember"]["condition"] = {
469
+ "kind": "expr",
470
+ "declarative": {"role": {"size": {"$param": "role_size"}}},
471
+ }
472
+ rt.execute_command(
473
+ "addGroupMember",
474
+ {"groupId": "eng", "userId": "alice", "role": "admin", "role_size": 5},
475
+ )
476
+ _method, req = client.calls[0]
477
+ assert req["ConditionExpression"] == "size(#f0) = :vf0"
478
+ assert req["ExpressionAttributeNames"]["#f0"] == "role"
479
+ assert req["ExpressionAttributeValues"][":vf0"] == {"N": "5"}
480
+
481
+
482
+ def test_conditional_raw_cond_literal_renders_verbatim():
483
+ """Issue #114-B: a raw `cond` write condition (`{ kind: raw }`) attaches its
484
+ pre-compiled expression + `#cr_*` names verbatim, binding each `:crN` literal
485
+ value (here 'banned') exactly as the TS `serializeRawCondition` emitted it.
486
+ """
487
+ client = FakeClient()
488
+ rt = make_runtime(client)
489
+ rt._commands["addGroupMember"]["condition"] = {
490
+ "kind": "raw",
491
+ "expression": "#cr_role <> :cr0 AND attribute_exists(#cr_role)",
492
+ "names": {"#cr_role": "role"},
493
+ "values": {":cr0": "banned"},
494
+ }
495
+ rt.execute_command(
496
+ "addGroupMember", {"groupId": "eng", "userId": "alice", "role": "admin"}
497
+ )
498
+ _method, req = client.calls[0]
499
+ assert req["ConditionExpression"] == (
500
+ "#cr_role <> :cr0 AND attribute_exists(#cr_role)"
501
+ )
502
+ assert req["ExpressionAttributeNames"]["#cr_role"] == "role"
503
+ assert plain(req["ExpressionAttributeValues"])[":cr0"] == "banned"
504
+
505
+
506
+ def test_conditional_raw_cond_binds_param_marker():
507
+ """Issue #114-B: a `{ $param }` value marker in a raw `cond` condition binds
508
+ from the typed caller param (here `cond_p0`), so TS and Python compare against
509
+ the same bound value.
510
+ """
511
+ client = FakeClient()
512
+ rt = make_runtime(client)
513
+ rt._commands["addGroupMember"]["params"] = {
514
+ **rt._commands["addGroupMember"]["params"],
515
+ "cond_p0": {"type": "string", "required": True},
516
+ }
517
+ rt._commands["addGroupMember"]["condition"] = {
518
+ "kind": "raw",
519
+ "expression": "#cr_role = :cr0",
520
+ "names": {"#cr_role": "role"},
521
+ "values": {":cr0": {"$param": "cond_p0"}},
522
+ }
523
+ rt.execute_command(
524
+ "addGroupMember",
525
+ {"groupId": "eng", "userId": "alice", "role": "admin", "cond_p0": "admin"},
526
+ )
527
+ _method, req = client.calls[0]
528
+ assert req["ConditionExpression"] == "#cr_role = :cr0"
529
+ assert plain(req["ExpressionAttributeValues"])[":cr0"] == "admin"
530
+
531
+
345
532
  # ── declarative transactions (issue #46) ────────────────────────────────────
346
533
 
347
534