graphddb-runtime 0.1.1__tar.gz → 0.2.1__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.1.1 → graphddb_runtime-0.2.1}/PKG-INFO +1 -1
  2. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/batch.py +2 -1
  3. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/filters.py +5 -3
  4. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/runtime.py +146 -15
  5. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/transactions.py +84 -0
  6. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/PKG-INFO +1 -1
  7. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/pyproject.toml +1 -1
  8. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_command.py +33 -14
  9. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_unit.py +168 -20
  10. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/README.md +0 -0
  11. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/__init__.py +0 -0
  12. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/async_runtime.py +0 -0
  13. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/concurrency.py +0 -0
  14. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/cursor.py +0 -0
  15. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/errors.py +0 -0
  16. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/hydration.py +0 -0
  17. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/limits.py +0 -0
  18. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/per_key_cursor.py +0 -0
  19. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/relations.py +0 -0
  20. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/templates.py +0 -0
  21. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/SOURCES.txt +0 -0
  22. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
  23. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/requires.txt +0 -0
  24. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/top_level.txt +0 -0
  25. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/setup.cfg +0 -0
  26. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_concurrency.py +0 -0
  27. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_contract_runtime.py +0 -0
  28. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration.py +0 -0
  29. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_compose.py +0 -0
  30. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_contract.py +0 -0
  31. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_edge_derive.py +0 -0
  32. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_edge_write.py +0 -0
  33. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_events.py +0 -0
  34. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_referential.py +0 -0
  35. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_relations.py +0 -0
  36. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_unique.py +0 -0
  37. {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_relations.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.1.1
3
+ Version: 0.2.1
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
@@ -152,7 +152,8 @@ class BatchWriteExecutor:
152
152
 
153
153
  ``BatchWriteItem`` carries **no conditions** (DynamoDB has no per-request
154
154
  ``ConditionExpression`` for it) and is **not atomic** — both are properties of
155
- the command-contract ``'batchWrite'`` mode (issue #64). The sleep is injected
155
+ the command-contract ``mode: 'parallel'`` coalesced fan-out (issue #64/#101).
156
+ The sleep is injected
156
157
  so unit tests can observe the backoff schedule without real delays.
157
158
  """
158
159
 
@@ -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
 
@@ -789,11 +836,11 @@ class GraphDDBRuntime:
789
836
  the method's declared mode:
790
837
 
791
838
  - ``single`` key → the one referenced write op in ``commands``;
792
- - ``keys[]`` + ``transact`` → one ``TransactWriteItems`` (the synthesized
839
+ - ``keys[]`` + ``transaction`` → one ``TransactWriteItems`` (the synthesized
793
840
  per-key ``forEach`` transaction; **atomic**, ≤25, condition-capable). An
794
841
  array of **>25 keys is rejected** — an atomic transaction cannot be split.
795
- - ``keys[]`` + ``batchWrite`` → a ``BatchWriteItem`` (**non-atomic**, **no
796
- conditions**), chunked ≤25 per request with ``UnprocessedItems`` retry.
842
+ - ``keys[]`` + ``parallel`` → a non-atomic per-key fan-out with partial
843
+ success (unconditioned put/delete coalesce into a ``BatchWriteItem``).
797
844
 
798
845
  ``params`` are the mutation values shared across every key (the body's
799
846
  ``params`` argument). For each key the runtime merges ``{**key, **params}``
@@ -810,9 +857,15 @@ class GraphDDBRuntime:
810
857
  shared = dict(params or {})
811
858
 
812
859
  if isinstance(key_or_keys, list):
813
- self._execute_command_batch(
860
+ outcome = self._execute_command_batch(
814
861
  contract_name, method_name, method, key_or_keys, shared
815
862
  )
863
+ # #101 `mode: 'parallel'` returns a partial-success result list; the
864
+ # atomic / batchWrite forms return None (fire-and-forget). Surface the
865
+ # parallel outcome to the caller wrapped as `{"results": [...]}`, matching
866
+ # the TS runtime's `CommandParallelReturn`.
867
+ if outcome is not None:
868
+ return {"results": outcome}
816
869
  return None
817
870
 
818
871
  # Single key → the referenced write surface, driven by key + params.
@@ -894,15 +947,19 @@ class GraphDDBRuntime:
894
947
  method: Mapping[str, Any],
895
948
  keys: List[Mapping[str, Any]],
896
949
  shared: Mapping[str, Any],
897
- ) -> None:
898
- """Apply a command method's array form per its declared batch target."""
950
+ ) -> Optional[List[Dict[str, Any]]]:
951
+ """Apply a command method's array form per its declared `mode` target.
952
+
953
+ Returns ``None`` for the atomic ``transaction`` form (fire-and-forget), or
954
+ the per-op partial-success result list for the #101 ``parallel`` form."""
899
955
  batch = method.get("batch")
900
956
  label = f"{contract_name}.{method_name}"
901
957
  if batch is None:
902
958
  raise ContractArityError(
903
959
  f"command method '{label}' was called with an array of keys, but it "
904
- f"declares no batched-write form. Declare a 'transact' or 'batchWrite' "
905
- f"batch on the method, or call it with a single key."
960
+ f"declares no key-array bulk form. Author the method with "
961
+ f"`mode: 'transaction'` / `mode: 'parallel'`, or call it with a "
962
+ f"single key."
906
963
  )
907
964
  mode = batch.get("mode")
908
965
  if mode == "transaction":
@@ -912,13 +969,57 @@ class GraphDDBRuntime:
912
969
  batch["transaction"], {**dict(shared), "keys": [dict(k) for k in keys]}
913
970
  )
914
971
  return
915
- if mode == "batchWrite":
916
- self._execute_batch_write(label, batch["operation"], keys, shared)
917
- return
972
+ if mode == "parallel":
973
+ # #101 — non-atomic per-key fan-out with partial success. This branch
974
+ # returns a per-op result list; the caller (execute_command_method)
975
+ # surfaces it.
976
+ return self._execute_command_parallel(
977
+ label, batch["operation"], keys, shared
978
+ )
918
979
  raise GraphDDBError( # pragma: no cover - serializer only emits the two above
919
980
  f"{label}: unknown batch resolution mode '{mode}'"
920
981
  )
921
982
 
983
+ def _execute_command_parallel(
984
+ self,
985
+ label: str,
986
+ command_id: str,
987
+ keys: List[Mapping[str, Any]],
988
+ shared: Mapping[str, Any],
989
+ ) -> List[Dict[str, Any]]:
990
+ """#101 ``mode: 'parallel'`` — non-atomic per-key fan-out, partial success.
991
+
992
+ Mirrors the TS ``executeParallelWrites``: when the per-key op carries no
993
+ condition and is put/delete, coalesce into a ``BatchWriteItem`` (chunk ≤25,
994
+ ``UnprocessedItems`` retry) and report every key ``{"ok": True}``; otherwise
995
+ issue each conditional write individually, collecting ``{"ok": bool,
996
+ "error"?}`` per key. A per-op failure NEVER aborts the others. The result
997
+ list is aligned to the input key order.
998
+ """
999
+ if not keys:
1000
+ return []
1001
+ spec = self._commands.get(command_id)
1002
+ if spec is None:
1003
+ raise ContractNotFoundError(
1004
+ f"{label}: referenced write op '{command_id}' is not present in "
1005
+ f"`commands`."
1006
+ )
1007
+ op_type = spec["type"]
1008
+ has_condition = spec.get("condition") is not None
1009
+ coalescible = not has_condition and op_type in ("PutItem", "DeleteItem")
1010
+ if coalescible:
1011
+ self._execute_batch_write(label, command_id, keys, shared)
1012
+ return [{"ok": True} for _ in keys]
1013
+ results: List[Dict[str, Any]] = []
1014
+ for key in keys:
1015
+ params = {**dict(key), **dict(shared)}
1016
+ try:
1017
+ self.execute_command(command_id, params)
1018
+ results.append({"ok": True})
1019
+ except Exception as exc: # noqa: BLE001 - per-op partial success
1020
+ results.append({"ok": False, "error": str(exc)})
1021
+ return results
1022
+
922
1023
  def _execute_batch_write(
923
1024
  self,
924
1025
  label: str,
@@ -930,8 +1031,9 @@ class GraphDDBRuntime:
930
1031
 
931
1032
  Reuses :class:`BatchWriteExecutor` (chunk ≤25, ``UnprocessedItems`` retry).
932
1033
  DynamoDB's ``BatchWriteItem`` supports only ``PutRequest`` / ``DeleteRequest``
933
- — a command whose single-key op is an ``UpdateItem`` cannot resolve its
934
- array form to a ``BatchWriteItem`` (it must declare ``transact``).
1034
+ — a command whose single-key op is an ``UpdateItem`` cannot coalesce its
1035
+ ``mode: 'parallel'`` array form into a ``BatchWriteItem`` (it falls back to
1036
+ per-key ``UpdateItem`` calls, or must declare ``mode: 'transaction'``).
935
1037
  """
936
1038
  spec = self._commands.get(command_id)
937
1039
  if spec is None:
@@ -942,8 +1044,8 @@ class GraphDDBRuntime:
942
1044
  op_type = spec["type"]
943
1045
  if op_type == "UpdateItem":
944
1046
  raise GraphDDBError(
945
- f"{label}: 'batchWrite' only supports put / delete (DynamoDB's "
946
- f"BatchWriteItem has no Update request). Declare a 'transact' batch "
1047
+ f"{label}: BatchWriteItem only supports put / delete (DynamoDB's "
1048
+ f"BatchWriteItem has no Update request). Declare `mode: 'transaction'` "
947
1049
  f"for a batched update."
948
1050
  )
949
1051
  requests: List[Dict[str, Any]] = []
@@ -1613,6 +1715,35 @@ class GraphDDBRuntime:
1613
1715
  values[v] = self._serializer.serialize(resolved)
1614
1716
  clauses.append(f"{n} = {v}")
1615
1717
  request["ConditionExpression"] = " AND ".join(clauses)
1718
+ elif kind == "expr":
1719
+ # The declarative operator tree (issue #114-A): resolve each
1720
+ # ``{"$param": name}`` marker to its typed caller-param value, then
1721
+ # compile the tree to a ConditionExpression with the SAME mechanics as
1722
+ # the read-side filter (``compile_filter``) so TS and Python emit an
1723
+ # identical expression / semantics. ``compile_filter`` allocates
1724
+ # ``#f`` / ``:vf`` aliases that never collide with the update ``#c`` /
1725
+ # ``:c`` namespace, so the compiled names/values merge cleanly.
1726
+ concrete = _resolve_condition_tree(condition["declarative"], params)
1727
+ compiled = compile_filter(concrete, self._serializer)
1728
+ if compiled is None:
1729
+ return
1730
+ names.update(compiled["ExpressionAttributeNames"])
1731
+ values = request.setdefault("ExpressionAttributeValues", {})
1732
+ values.update(compiled["ExpressionAttributeValues"])
1733
+ request["ConditionExpression"] = compiled["FilterExpression"]
1734
+ elif kind == "raw":
1735
+ # A raw ``cond`` write condition (issue #114-B): the expression and
1736
+ # the ``#cr_*`` name aliases are finished; bind each ``:crN`` value to
1737
+ # its literal, or — when it is a ``{"$param": name}`` marker — to the
1738
+ # typed caller-param value, then serialize. ``#cr_*`` / ``:cr*`` never
1739
+ # collide with the update namespace, so they merge cleanly. The
1740
+ # expression mirrors the TS ``serializeRawCondition`` output verbatim.
1741
+ names.update(condition["names"])
1742
+ values = request.setdefault("ExpressionAttributeValues", {})
1743
+ for alias, raw_val in condition["values"].items():
1744
+ resolved = _resolve_condition_leaf(raw_val, params)
1745
+ values[alias] = self._serializer.serialize(resolved)
1746
+ request["ConditionExpression"] = condition["expression"]
1616
1747
 
1617
1748
  def _add_key_attributes(
1618
1749
  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.1.1
3
+ Version: 0.2.1
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.1.1"
7
+ version = "0.2.1"
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"
@@ -6,11 +6,11 @@ fixtures (``UserCommands`` / ``MembershipCommands`` in operations.json) and
6
6
  asserts the **actual persisted state** after each write:
7
7
 
8
8
  - single key → one write op (update / put / delete);
9
- - key array + 'transact' → one atomic TransactWriteItems (with a per-item
10
- condition: applies on success, rolls the WHOLE batch back on failure);
11
- - key array + 'batchWrite' → a non-atomic BatchWriteItem (put / delete),
12
- chunked > 25 with retry;
13
- - a >25-key 'transact' array is rejected (an atomic batch cannot be split).
9
+ - key array + `mode: 'transaction'` → one atomic TransactWriteItems (with a
10
+ per-item condition: applies on success, rolls the WHOLE batch back on failure);
11
+ - key array + `mode: 'parallel'` → a non-atomic per-key fan-out (put / delete
12
+ coalesce into BatchWriteItem), chunked > 25 with retry;
13
+ - a >25-key `mode: 'transaction'` array is rejected (an atomic batch cannot be split).
14
14
 
15
15
  This mirrors the TS suite (__tests__/integration/command-runtime.test.ts) over
16
16
  the **same SSoT**, proving the two runtimes produce identical effects.
@@ -147,7 +147,10 @@ def test_single_update_applies(rt, client):
147
147
 
148
148
  def test_single_put_then_delete(rt, client):
149
149
  rt.execute_command_method(
150
- "MembershipCommands", "add", {"groupId": "eng", "userId": "solo"}, {"role": "lead"}
150
+ "MembershipCommands",
151
+ "add",
152
+ {"groupId": "eng", "userId": "solo"},
153
+ {"role": "lead", "joinedAt": "2021-06-01T00:00:00.000Z"},
151
154
  )
152
155
  row = _membership(client, "eng", "solo")
153
156
  assert row is not None and row["role"]["S"] == "lead"
@@ -172,9 +175,21 @@ def test_transact_array_applies_atomically(rt, client):
172
175
 
173
176
 
174
177
  def test_transact_condition_rolls_back_whole_batch_on_failure(rt, client):
175
- # Make e 'pending' so its `status = active` condition fails; the atomic batch
176
- # must then roll d back too.
177
- rt.execute_command_method("UserCommands", "disable", {"userId": "e"}, {"status": "pending"})
178
+ # Make e 'pending' so its literal `status = active` condition fails; the atomic
179
+ # batch must then roll d back too. (`disable`'s input is `literal('disabled')`,
180
+ # so the 'pending' precondition is written with a raw put rather than the command.)
181
+ client.put_item(
182
+ TableName=TABLE,
183
+ Item={
184
+ "PK": {"S": "USER#e"},
185
+ "SK": {"S": "PROFILE"},
186
+ "userId": {"S": "e"},
187
+ "name": {"S": "User e"},
188
+ "email": {"S": "e@x.com"},
189
+ "status": {"S": "pending"},
190
+ "createdAt": {"S": "2021-01-01T00:00:00.000Z"},
191
+ },
192
+ )
178
193
  assert _user_status(client, "e") == "pending"
179
194
 
180
195
  with pytest.raises(OperationExecutionError):
@@ -182,7 +197,7 @@ def test_transact_condition_rolls_back_whole_batch_on_failure(rt, client):
182
197
  "UserCommands",
183
198
  "disableIfActive",
184
199
  [{"userId": "d"}, {"userId": "e"}],
185
- {"status": "disabled", "expected": "active"},
200
+ {"status": "disabled"},
186
201
  )
187
202
  # d unchanged (rolled back); e unchanged.
188
203
  assert _user_status(client, "d") == "active"
@@ -194,7 +209,7 @@ def test_transact_condition_applies_when_all_hold(rt, client):
194
209
  "UserCommands",
195
210
  "disableIfActive",
196
211
  [{"userId": "f"}],
197
- {"status": "disabled", "expected": "active"},
212
+ {"status": "disabled"},
198
213
  )
199
214
  assert _user_status(client, "f") == "disabled"
200
215
 
@@ -205,12 +220,14 @@ def test_transact_rejects_over_25_keys(rt, client):
205
220
  rt.execute_command_method("UserCommands", "disable", keys, {"status": "disabled"})
206
221
 
207
222
 
208
- # ── array + batchWrite → non-atomic BatchWriteItem (chunked > 25) ────────────
223
+ # ── array + mode:'parallel' → non-atomic BatchWriteItem (chunked > 25) ───────
209
224
 
210
225
 
211
226
  def test_batch_write_puts_and_chunks_over_25(rt, client):
212
227
  keys = [{"groupId": "big", "userId": f"m{i}"} for i in range(30)]
213
- rt.execute_command_method("MembershipCommands", "add", keys, {"role": "member"})
228
+ rt.execute_command_method(
229
+ "MembershipCommands", "add", keys, {"role": "member", "joinedAt": "2021-06-01T00:00:00.000Z"}
230
+ )
214
231
  assert _membership(client, "big", "m0")["role"]["S"] == "member"
215
232
  assert _membership(client, "big", "m24")["role"]["S"] == "member"
216
233
  assert _membership(client, "big", "m29")["role"]["S"] == "member"
@@ -218,7 +235,9 @@ def test_batch_write_puts_and_chunks_over_25(rt, client):
218
235
 
219
236
  def test_batch_write_deletes(rt, client):
220
237
  keys = [{"groupId": "big", "userId": f"m{i}"} for i in range(30)]
221
- rt.execute_command_method("MembershipCommands", "add", keys, {"role": "member"})
238
+ rt.execute_command_method(
239
+ "MembershipCommands", "add", keys, {"role": "member", "joinedAt": "2021-06-01T00:00:00.000Z"}
240
+ )
222
241
  rt.execute_command_method("MembershipCommands", "remove", keys)
223
242
  assert _membership(client, "big", "m0") is None
224
243
  assert _membership(client, "big", "m29") is None
@@ -342,6 +342,145 @@ def test_conditional_put_emits_attribute_not_exists_on_named_field():
342
342
  assert req["ExpressionAttributeNames"]["#ce"] == "PK"
343
343
 
344
344
 
345
+ def test_conditional_expr_tree_renders_via_compile_filter():
346
+ """Issue #114-A: a declarative operator tree (`{ kind: expr, declarative }`)
347
+ resolves its `{$param}` markers (typed) and compiles to a ConditionExpression
348
+ with the read-side filter mechanics (`#f`/`:vf` aliases).
349
+ """
350
+ client = FakeClient()
351
+ rt = make_runtime(client)
352
+ rt._commands["addGroupMember"]["params"] = {
353
+ **rt._commands["addGroupMember"]["params"],
354
+ "role_0": {"type": "string", "required": True},
355
+ "role_1": {"type": "string", "required": True},
356
+ }
357
+ rt._commands["addGroupMember"]["condition"] = {
358
+ "kind": "expr",
359
+ "declarative": {
360
+ "and": [
361
+ {"role": {"in": [{"$param": "role_0"}, {"$param": "role_1"}]}},
362
+ {"role": {"beginsWith": {"$param": "role"}}},
363
+ ]
364
+ },
365
+ }
366
+ rt.execute_command(
367
+ "addGroupMember",
368
+ {
369
+ "groupId": "eng",
370
+ "userId": "alice",
371
+ "role": "admin",
372
+ "role_0": "admin",
373
+ "role_1": "owner",
374
+ },
375
+ )
376
+ _method, req = client.calls[0]
377
+ assert req["ConditionExpression"] == (
378
+ "(#f0 IN (:vf0, :vf1) AND begins_with(#f0, :vf2))"
379
+ )
380
+ assert req["ExpressionAttributeNames"]["#f0"] == "role"
381
+ values = plain(req["ExpressionAttributeValues"])
382
+ assert values[":vf0"] == "admin"
383
+ assert values[":vf1"] == "owner"
384
+ assert values[":vf2"] == "admin"
385
+
386
+
387
+ def test_conditional_expr_numeric_comparison_keeps_type():
388
+ """A numeric `{$param}` operand stays numeric (serialized as N), so `gt`
389
+ compares numerically rather than against a stringified value.
390
+ """
391
+ client = FakeClient()
392
+ rt = make_runtime(client)
393
+ rt._commands["disableUser"]["params"] = {
394
+ **rt._commands["disableUser"]["params"],
395
+ "minVersion": {"type": "number", "required": True},
396
+ }
397
+ rt._commands["disableUser"]["condition"] = {
398
+ "kind": "expr",
399
+ "declarative": {"version": {"gt": {"$param": "minVersion"}}},
400
+ }
401
+ rt.execute_command(
402
+ "disableUser", {"userId": "alice", "status": "disabled", "minVersion": 3}
403
+ )
404
+ _method, req = client.calls[0]
405
+ assert req["ConditionExpression"] == "#f0 > :vf0"
406
+ assert req["ExpressionAttributeValues"][":vf0"] == {"N": "3"}
407
+
408
+
409
+ def test_conditional_expr_size_renders_size_function():
410
+ """Audit fix (issue #114-A): the `size` operator must compile to
411
+ `size(#f) = :v`, not be silently dropped. The numeric length operand stays
412
+ N-typed.
413
+ """
414
+ client = FakeClient()
415
+ rt = make_runtime(client)
416
+ rt._commands["addGroupMember"]["params"] = {
417
+ **rt._commands["addGroupMember"]["params"],
418
+ "role_size": {"type": "number", "required": True},
419
+ }
420
+ rt._commands["addGroupMember"]["condition"] = {
421
+ "kind": "expr",
422
+ "declarative": {"role": {"size": {"$param": "role_size"}}},
423
+ }
424
+ rt.execute_command(
425
+ "addGroupMember",
426
+ {"groupId": "eng", "userId": "alice", "role": "admin", "role_size": 5},
427
+ )
428
+ _method, req = client.calls[0]
429
+ assert req["ConditionExpression"] == "size(#f0) = :vf0"
430
+ assert req["ExpressionAttributeNames"]["#f0"] == "role"
431
+ assert req["ExpressionAttributeValues"][":vf0"] == {"N": "5"}
432
+
433
+
434
+ def test_conditional_raw_cond_literal_renders_verbatim():
435
+ """Issue #114-B: a raw `cond` write condition (`{ kind: raw }`) attaches its
436
+ pre-compiled expression + `#cr_*` names verbatim, binding each `:crN` literal
437
+ value (here 'banned') exactly as the TS `serializeRawCondition` emitted it.
438
+ """
439
+ client = FakeClient()
440
+ rt = make_runtime(client)
441
+ rt._commands["addGroupMember"]["condition"] = {
442
+ "kind": "raw",
443
+ "expression": "#cr_role <> :cr0 AND attribute_exists(#cr_role)",
444
+ "names": {"#cr_role": "role"},
445
+ "values": {":cr0": "banned"},
446
+ }
447
+ rt.execute_command(
448
+ "addGroupMember", {"groupId": "eng", "userId": "alice", "role": "admin"}
449
+ )
450
+ _method, req = client.calls[0]
451
+ assert req["ConditionExpression"] == (
452
+ "#cr_role <> :cr0 AND attribute_exists(#cr_role)"
453
+ )
454
+ assert req["ExpressionAttributeNames"]["#cr_role"] == "role"
455
+ assert plain(req["ExpressionAttributeValues"])[":cr0"] == "banned"
456
+
457
+
458
+ def test_conditional_raw_cond_binds_param_marker():
459
+ """Issue #114-B: a `{ $param }` value marker in a raw `cond` condition binds
460
+ from the typed caller param (here `cond_p0`), so TS and Python compare against
461
+ the same bound value.
462
+ """
463
+ client = FakeClient()
464
+ rt = make_runtime(client)
465
+ rt._commands["addGroupMember"]["params"] = {
466
+ **rt._commands["addGroupMember"]["params"],
467
+ "cond_p0": {"type": "string", "required": True},
468
+ }
469
+ rt._commands["addGroupMember"]["condition"] = {
470
+ "kind": "raw",
471
+ "expression": "#cr_role = :cr0",
472
+ "names": {"#cr_role": "role"},
473
+ "values": {":cr0": {"$param": "cond_p0"}},
474
+ }
475
+ rt.execute_command(
476
+ "addGroupMember",
477
+ {"groupId": "eng", "userId": "alice", "role": "admin", "cond_p0": "admin"},
478
+ )
479
+ _method, req = client.calls[0]
480
+ assert req["ConditionExpression"] == "#cr_role = :cr0"
481
+ assert plain(req["ExpressionAttributeValues"])[":cr0"] == "admin"
482
+
483
+
345
484
  # ── declarative transactions (issue #46) ────────────────────────────────────
346
485
 
347
486
 
@@ -901,7 +1040,7 @@ def test_command_method_single_update_runs_one_update_item():
901
1040
 
902
1041
 
903
1042
  def test_command_method_transact_array_runs_one_transaction():
904
- """A key array + 'transact' → ONE TransactWriteItems (one item per key)."""
1043
+ """A key array + `mode: 'transaction'` → ONE TransactWriteItems (one item per key)."""
905
1044
  client = FakeClient()
906
1045
  rt = make_runtime(client)
907
1046
  rt.execute_command_method(
@@ -920,26 +1059,28 @@ def test_command_method_transact_array_runs_one_transaction():
920
1059
 
921
1060
 
922
1061
  def test_command_method_transact_carries_per_item_condition():
923
- """A conditional 'transact' batch attaches the equality condition per item."""
1062
+ """A conditional `mode: 'transaction'` batch attaches the equality condition per item."""
924
1063
  client = FakeClient()
925
1064
  rt = make_runtime(client)
926
1065
  rt.execute_command_method(
927
1066
  "UserCommands",
928
1067
  "disableIfActive",
929
1068
  [{"userId": "a"}, {"userId": "b"}],
930
- {"status": "disabled", "expected": "active"},
1069
+ {"status": "disabled"},
931
1070
  )
932
1071
  method, req = client.calls[0]
933
1072
  assert method == "transact_write_items"
934
1073
  update = req["TransactItems"][0]["Update"]
935
1074
  assert "ConditionExpression" in update
936
- assert "active" in [
937
- _DESER.deserialize(v) for v in update["ExpressionAttributeValues"].values()
938
- ]
1075
+ # #101 descriptor form: the gate is a concrete-literal equality (status == 'active');
1076
+ # the written value is the 'disabled' status param. Both appear per item.
1077
+ values = [_DESER.deserialize(v) for v in update["ExpressionAttributeValues"].values()]
1078
+ assert "active" in values
1079
+ assert "disabled" in values
939
1080
 
940
1081
 
941
1082
  def test_command_method_transact_rejects_over_25_keys():
942
- """A >25-key 'transact' array is rejected — an atomic tx cannot be split."""
1083
+ """A >25-key `mode: 'transaction'` array is rejected — an atomic tx cannot be split."""
943
1084
  client = FakeClient()
944
1085
  rt = make_runtime(client)
945
1086
  keys = [{"userId": f"u{i}"} for i in range(26)]
@@ -949,32 +1090,38 @@ def test_command_method_transact_rejects_over_25_keys():
949
1090
  assert all(m != "transact_write_items" for m, _ in client.calls)
950
1091
 
951
1092
 
952
- def test_command_method_batch_write_array_runs_batch_write_item():
953
- """A key array + 'batchWrite' a BatchWriteItem of PutRequests (no conditions)."""
1093
+ def test_command_method_parallel_guarded_create_runs_individual_conditional_puts():
1094
+ """#101 `mode: 'parallel'` + a guarded create (attribute_not_exists) → INDIVIDUAL
1095
+ conditional PutItem writes (not a condition-less BatchWriteItem), with per-key
1096
+ partial-success results aligned to key order."""
954
1097
  client = FakeClient()
955
1098
  rt = make_runtime(client)
956
- rt.execute_command_method(
1099
+ out = rt.execute_command_method(
957
1100
  "MembershipCommands",
958
1101
  "add",
959
1102
  [{"groupId": "eng", "userId": "a"}, {"groupId": "eng", "userId": "b"}],
960
- {"role": "member"},
1103
+ {"role": "member", "joinedAt": "1970-01-01T00:00:00Z"},
961
1104
  )
962
- assert len(client.calls) == 1
963
- method, req = client.calls[0]
964
- assert method == "batch_write_item"
965
- (table, requests), = req["RequestItems"].items()
966
- assert len(requests) == 2
967
- item0 = plain(requests[0]["PutRequest"]["Item"])
1105
+ # A guarded create carries a condition, so the parallel path issues one PutItem
1106
+ # per key (concurrently) — never a coalesced BatchWriteItem.
1107
+ methods = [m for m, _ in client.calls]
1108
+ assert methods == ["put_item", "put_item"]
1109
+ first = client.calls[0][1]
1110
+ assert "ConditionExpression" in first
1111
+ item0 = plain(first["Item"])
968
1112
  assert item0["PK"] == "GROUP#eng"
969
1113
  assert item0["SK"] == "USER#a"
970
1114
  assert item0["role"] == "member"
1115
+ # Partial-success result: both keys ok, aligned to input order.
1116
+ assert out == {"results": [{"ok": True}, {"ok": True}]}
971
1117
 
972
1118
 
973
- def test_command_method_batch_write_delete_runs_delete_requests():
974
- """A 'batchWrite' delete resolves to DeleteRequests keyed per input key."""
1119
+ def test_command_method_parallel_unconditioned_delete_coalesces_batch_write():
1120
+ """#101 `mode: 'parallel'` + an UNconditioned delete coalesces into a
1121
+ BatchWriteItem (DeleteRequests), reporting every key ok."""
975
1122
  client = FakeClient()
976
1123
  rt = make_runtime(client)
977
- rt.execute_command_method(
1124
+ out = rt.execute_command_method(
978
1125
  "MembershipCommands",
979
1126
  "remove",
980
1127
  [{"groupId": "eng", "userId": "a"}, {"groupId": "eng", "userId": "b"}],
@@ -986,6 +1133,7 @@ def test_command_method_batch_write_delete_runs_delete_requests():
986
1133
  "PK": "GROUP#eng",
987
1134
  "SK": "USER#a",
988
1135
  }
1136
+ assert out == {"results": [{"ok": True}, {"ok": True}]}
989
1137
 
990
1138
 
991
1139
  def test_command_method_unknown_contract_raises():