graphddb-runtime 1.0.9__tar.gz → 1.1.0__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 (56) hide show
  1. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/PKG-INFO +2 -2
  2. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/behavior_exec.py +199 -25
  3. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/runtime.py +17 -3
  4. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime.egg-info/PKG-INFO +2 -2
  5. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime.egg-info/requires.txt +1 -1
  6. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/pyproject.toml +2 -2
  7. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_crosslang_python_cells.py +23 -2
  8. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_error_policy.py +52 -26
  9. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_unit.py +8 -1
  10. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/README.md +0 -0
  11. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/__init__.py +0 -0
  12. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/async_runtime.py +0 -0
  13. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/batch.py +0 -0
  14. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/concurrency.py +0 -0
  15. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/cursor.py +0 -0
  16. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/errors.py +0 -0
  17. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/filters.py +0 -0
  18. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/handler_registry.py +0 -0
  19. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/hydration.py +0 -0
  20. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/limits.py +0 -0
  21. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/middleware.py +0 -0
  22. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/per_key_cursor.py +0 -0
  23. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/templates.py +0 -0
  24. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/transactions.py +0 -0
  25. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime.egg-info/SOURCES.txt +0 -0
  26. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
  27. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime.egg-info/top_level.txt +0 -0
  28. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/setup.cfg +0 -0
  29. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_atomic_add_command.py +0 -0
  30. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_concurrency.py +0 -0
  31. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_contract_runtime.py +0 -0
  32. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_description.py +0 -0
  33. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_description_followups.py +0 -0
  34. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_description_jsdoc.py +0 -0
  35. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_generated_module.py +0 -0
  36. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_guard_eval.py +0 -0
  37. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_handler_registry.py +0 -0
  38. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration.py +0 -0
  39. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_batch_write.py +0 -0
  40. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_binding_contract.py +0 -0
  41. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_command.py +0 -0
  42. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_compose.py +0 -0
  43. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_contract.py +0 -0
  44. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_edge_derive.py +0 -0
  45. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_edge_write.py +0 -0
  46. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_events.py +0 -0
  47. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_maintain.py +0 -0
  48. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_middleware.py +0 -0
  49. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_referential.py +0 -0
  50. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_relations.py +0 -0
  51. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_unique.py +0 -0
  52. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_maintain.py +0 -0
  53. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_middleware.py +0 -0
  54. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_relations.py +0 -0
  55. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_spec_version_guard.py +0 -0
  56. {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_ttl.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 1.0.9
3
+ Version: 1.1.0
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
7
7
  Description-Content-Type: text/markdown
8
8
  Requires-Dist: boto3>=1.26
9
- Requires-Dist: behavior-contracts==0.7.3
9
+ Requires-Dist: behavior-contracts==0.8.2
10
10
  Provides-Extra: test
11
11
  Requires-Dist: pytest>=7.0; extra == "test"
12
12
 
@@ -144,12 +144,119 @@ def _node_parts(node: Mapping[str, Any]) -> Tuple[str, Optional[Mapping[str, Any
144
144
  def _relation_path_of(node_id: str) -> Tuple[str, ...]:
145
145
  """``ctx.relationPath`` for a node — the non-``items`` tokens of its id
146
146
  (the id IS the old ``resultPath`` sans ``$.``; ``root`` → ``()``). Identical
147
- to the old :meth:`GraphDDBRuntime._relation_path`."""
147
+ to the old :meth:`GraphDDBRuntime._relation_path`. Used ONLY for the LEGACY
148
+ imperative id scheme (dotted result-path ids); a genuinely declarative read
149
+ (issue #350) uses :func:`_relation_paths_from_output` instead."""
148
150
  if node_id == "root":
149
151
  return ()
150
152
  return tuple(t for t in node_id.split(".") if t != "items")
151
153
 
152
154
 
155
+ def _relation_paths_from_output(component: Mapping[str, Any]) -> Dict[str, Tuple[str, ...]]:
156
+ """Map each body-node id → its ``relation_path`` from the component ``output``
157
+ (id-scheme-INDEPENDENT — issue #350). A GENUINELY declarative read
158
+ (``compileBehaviors``) has recorder-scan node ids (``n0``/``n1``) that no longer
159
+ encode the relation path, but its ``output`` Φ merge names each ROOT-LEVEL
160
+ relation by property (``{obj:{root:{ref:[<rootId>]}, members:{ref:[<n1>]}, …}}``),
161
+ so the relation path of node ``<n1>`` is ``("members",)`` and the root
162
+ (``root`` key) is ``()``. Falls back to the legacy id-derived path for a node not
163
+ named in the output (defensive).
164
+
165
+ Since #350 each root-level relation is authored as a ``when``-gate, so its
166
+ output ref is the Φ CONDNODE (``{ref:[<cond>]}``) whose ``then`` branch refs the
167
+ gated READ node. This resolver follows the cond ``then`` ref so the relation
168
+ path lands on the READ node (the one whose handler carries a ``ctx``), matching
169
+ the pre-#350 behavior where the relation Query directly received ``("members",)``."""
170
+ out = component.get("output", {})
171
+ by_id = {n["id"]: n for n in component.get("body", [])}
172
+
173
+ def _read_node_id(node_id: str) -> str:
174
+ # Resolve a Φ ref to the READ node it names — through a CondNode gate
175
+ # (`then: {ref:[<readNode>]}`), so the relation path lands on the handler node.
176
+ node = by_id.get(node_id)
177
+ if node is not None and "cond" in node:
178
+ then = node["cond"].get("then")
179
+ then_ref = then.get("ref") if isinstance(then, Mapping) else None
180
+ if isinstance(then_ref, list) and then_ref:
181
+ return _read_node_id(then_ref[0])
182
+ return node_id
183
+
184
+ paths: Dict[str, Tuple[str, ...]] = {}
185
+ obj = out.get("obj") if isinstance(out, Mapping) else None
186
+ if obj is None:
187
+ # A single-node read (`{ref:[id]}`) — the sole node is the root.
188
+ ref = out.get("ref") if isinstance(out, Mapping) else None
189
+ if isinstance(ref, list) and ref:
190
+ paths[_read_node_id(ref[0])] = ()
191
+ return paths
192
+ # Per root-level relation, the CONNECTION node (the gated Query the cursor names)
193
+ # gets `(prop,)`; carry the connection→prop mapping so the depth-2+ chain maps that
194
+ # ride that connection's elements derive their path from it (below).
195
+ conn_prop: Dict[str, str] = {}
196
+ for prop, spec_node in obj.items():
197
+ ref = spec_node.get("ref") if isinstance(spec_node, Mapping) else None
198
+ # A chained connection carries `{obj:{cursor:{ref:[connId]},…}}`; its id is
199
+ # the cursor ref head. A plain relation / root is a bare `{ref:[id]}`.
200
+ if ref is None and isinstance(spec_node, Mapping) and "obj" in spec_node:
201
+ cur = spec_node["obj"].get("cursor", {})
202
+ ref = cur.get("ref") if isinstance(cur, Mapping) else None
203
+ if isinstance(ref, list) and ref:
204
+ read_id = _read_node_id(ref[0])
205
+ paths[read_id] = () if prop == "root" else (prop,)
206
+ if prop != "root":
207
+ conn_prop[read_id] = prop
208
+
209
+ # Depth-2+ chain maps (issue #350): a `.map(cb,{into})` node rides a connection's
210
+ # elements. Its relation path is the CONNECTION's path + the ordered `into` keys of
211
+ # its map-ancestors (id-scheme-INDEPENDENT — derived from `map.parent`/`map.into`,
212
+ # NOT the old dotted node id). `group` (parent = the connection gate) →
213
+ # `(groups, group)`; `permissions` (parent = the `group` map) →
214
+ # `(groups, group, permissions)`.
215
+ for node in component.get("body", []):
216
+ m = node.get("map") if isinstance(node, Mapping) else None
217
+ if not isinstance(m, Mapping) or "into" not in m:
218
+ continue
219
+ # Walk map-ancestors, collecting `into` keys outermost→this, until the chain
220
+ # reaches a non-map parent (the connection / its gate). Resolve that base node
221
+ # (through a cond gate) to a node with a known relation path.
222
+ intos: List[str] = []
223
+ cur_node: Optional[Mapping[str, Any]] = node
224
+ base_path: Optional[Tuple[str, ...]] = None
225
+ seen: set = set()
226
+ while isinstance(cur_node, Mapping):
227
+ cm = cur_node.get("map")
228
+ if isinstance(cm, Mapping) and "into" in cm:
229
+ intos.append(cm["into"])
230
+ over = cm.get("over", {})
231
+ over_ref = over.get("ref") if isinstance(over, Mapping) else None
232
+ parent_id = (
233
+ over_ref[0]
234
+ if isinstance(over_ref, list) and over_ref
235
+ else cm.get("parent")
236
+ )
237
+ else:
238
+ parent_id = None
239
+ if parent_id is None or parent_id in seen:
240
+ break
241
+ seen.add(parent_id)
242
+ base = by_id.get(parent_id)
243
+ resolved = _read_node_id(parent_id) if base is not None else parent_id
244
+ if resolved in paths:
245
+ base_path = paths[resolved]
246
+ break
247
+ cur_node = base
248
+ if base_path is not None:
249
+ paths[node["id"]] = base_path + tuple(reversed(intos))
250
+ return paths
251
+
252
+
253
+ def relation_paths_from_output(component: Mapping[str, Any]) -> Dict[str, Tuple[str, ...]]:
254
+ """Public alias of :func:`_relation_paths_from_output` (the id-scheme-independent
255
+ node-id → relation-path map — issue #350). Consumed by the runtime's
256
+ relation-depth limit check so it too is id-scheme-independent."""
257
+ return _relation_paths_from_output(component)
258
+
259
+
153
260
  def _reroot_for_template(node: Any) -> Any:
154
261
  """Rewrite every ``ref``/``refOpt`` root so the node renders through
155
262
  ``render_key_expr_to_template`` (whose grammar knows only ``input``/``result``
@@ -233,8 +340,24 @@ def execute_read_component(
233
340
 
234
341
  # Per-node static config (handler-injected, C4): entity meta for hydration,
235
342
  # select, key attrs, relation path — derived from the RAW port IR.
343
+ #
344
+ # Root + relation-path derivation is id-scheme-INDEPENDENT (issue #350): the
345
+ # ROOT is the node with NO ``parent`` wire (true in BOTH the legacy dotted-id
346
+ # scheme — where the root is also literally ``"root"`` — and the genuinely
347
+ # declarative ``compileBehaviors`` scheme with recorder-scan ids ``n0``/``n1``).
348
+ # The relation path comes from the component ``output`` Φ property names
349
+ # (:func:`_relation_paths_from_output`), which name each relation regardless of
350
+ # its node id; the legacy id-derived path is the fallback for a node absent from
351
+ # the output.
352
+ output_paths = _relation_paths_from_output(component)
236
353
  cfg: Dict[str, Dict[str, Any]] = {}
237
354
  for node in body:
355
+ # A CondNode (issue #350: each root-level relation is authored as a
356
+ # `when`-gate, so the component carries `cond` nodes) has NO ports and NO
357
+ # handler — bc's ``run_behavior`` evaluates it internally (the gate + Φ
358
+ # merge). It needs no per-node handler config, so skip it here.
359
+ if "cond" in node:
360
+ continue
238
361
  node_id, _m, ports = _node_parts(node)
239
362
  pseudo, key_attrs = _pseudo_read_op(ports)
240
363
  cfg[node_id] = {
@@ -242,8 +365,10 @@ def execute_read_component(
242
365
  "projection": pseudo["projection"],
243
366
  "entity_meta": rt._resolve_entity(pseudo),
244
367
  "key_attrs": key_attrs,
245
- "relation_path": _relation_path_of(node_id),
246
- "is_root": node_id == "root",
368
+ "relation_path": output_paths.get(node_id, _relation_path_of(node_id)),
369
+ "is_root": node.get("parent") is None
370
+ and node.get("map") is None
371
+ and node.get("fanout") is None,
247
372
  }
248
373
 
249
374
  def _key_of(ports: Mapping[str, Any], key_attrs: List[str]) -> Dict[str, Any]:
@@ -549,10 +674,12 @@ def _fold_read_output(
549
674
  result[prop] = val
550
675
  elif "obj" in spec_node:
551
676
  # Chained connection: {cursor, items:<augmented tail>} — re-nest the
552
- # per-element flat `into` keys along each chain map's id.
677
+ # per-element flat `into` keys along each chain map's PLACEMENT (derived
678
+ # from the `map.parent`/`map.into` chain, id-scheme-INDEPENDENT — #350).
553
679
  conn_id = spec_node["obj"]["cursor"]["ref"][0]
680
+ placements = _chain_map_placements(conn_id, body)
554
681
  elements = [
555
- _fold_chain_element(el, conn_id, body) for el in val["items"]
682
+ _fold_chain_element(el, placements) for el in val["items"]
556
683
  ]
557
684
  result[prop] = {"cursor": val["cursor"], "items": elements}
558
685
  else: # pragma: no cover - closed emitter vocabulary
@@ -596,37 +723,84 @@ def _fold_read_output(
596
723
  return result
597
724
 
598
725
 
726
+ def _chain_map_placements(
727
+ conn_id: str, body: List[Mapping[str, Any]]
728
+ ) -> List[Tuple[str, List[str]]]:
729
+ """Every chain-map node riding the connection ``conn_id`` (the cursor-ref head —
730
+ a gated Query / its cond) paired with its element PLACEMENT: the ordered ``into``
731
+ keys of its map-ancestors, outermost→this (id-scheme-INDEPENDENT — #350). A map
732
+ whose parent is the connection is ``[into]`` (a flat attach, first chain link); a
733
+ map whose parent is another map is ``[…parent placement, into]`` (nested — its
734
+ ``into`` re-nests under the parent's placement). Ordered shallowest→deepest so a
735
+ parent's key exists before a child re-nests under it."""
736
+ by_id = {n["id"]: n for n in body}
737
+
738
+ def _is_conn(node_id: str) -> bool:
739
+ # The connection base is `conn_id` itself OR the READ node its cond gate wraps
740
+ # (`then: {ref:[<read>]}`), so a map whose `over` refs either resolves as flat.
741
+ if node_id == conn_id:
742
+ return True
743
+ base = by_id.get(conn_id)
744
+ if base is not None and "cond" in base:
745
+ then = base["cond"].get("then")
746
+ then_ref = then.get("ref") if isinstance(then, Mapping) else None
747
+ if isinstance(then_ref, list) and then_ref and then_ref[0] == node_id:
748
+ return True
749
+ return False
750
+
751
+ placements: Dict[str, List[str]] = {}
752
+ for node in body:
753
+ m = node.get("map") if isinstance(node, Mapping) else None
754
+ if not isinstance(m, Mapping) or "into" not in m:
755
+ continue
756
+ intos: List[str] = []
757
+ cur: Optional[Mapping[str, Any]] = node
758
+ rides_conn = False
759
+ seen: set = set()
760
+ while isinstance(cur, Mapping):
761
+ cm = cur.get("map")
762
+ if not (isinstance(cm, Mapping) and "into" in cm):
763
+ break
764
+ intos.append(cm["into"])
765
+ over = cm.get("over", {})
766
+ over_ref = over.get("ref") if isinstance(over, Mapping) else None
767
+ parent_id = (
768
+ over_ref[0]
769
+ if isinstance(over_ref, list) and over_ref
770
+ else cm.get("parent")
771
+ )
772
+ if parent_id is None or parent_id in seen:
773
+ break
774
+ seen.add(parent_id)
775
+ if _is_conn(parent_id):
776
+ rides_conn = True
777
+ break
778
+ cur = by_id.get(parent_id)
779
+ if rides_conn:
780
+ placements[node["id"]] = list(reversed(intos))
781
+ return sorted(placements.items(), key=lambda kv: len(kv[1]))
782
+
783
+
599
784
  def _fold_chain_element(
600
- element: Any, conn_id: str, body: List[Mapping[str, Any]]
785
+ element: Any, placements: List[Tuple[str, List[str]]]
601
786
  ) -> Any:
602
- """Re-nest one augmented connection element: for each chain map under
603
- ``conn_id`` whose placement is DEEPER than the element itself, move its flat
604
- ``into`` key to the placement its node id names."""
787
+ """Re-nest one augmented connection element: for each chain map whose PLACEMENT
788
+ is DEEPER than a flat attach (``len > 1``), move its flat ``into`` key to the
789
+ nested placement its map-parent chain names (``permissions`` under ``group``)."""
605
790
  if not isinstance(element, dict):
606
791
  return element
607
792
  el = dict(element)
608
- prefix = conn_id.split(".")
609
- for node in body:
610
- m = node.get("map")
611
- if m is None or "into" not in m:
612
- continue
613
- node_tokens = node["id"].split(".")
614
- if node_tokens[: len(prefix)] != prefix or len(node_tokens) == len(prefix):
615
- continue
616
- rel = node_tokens[len(prefix):]
617
- if rel and rel[-1] == "items":
618
- rel = rel[:-1]
619
- if len(rel) <= 1:
793
+ for _node_id, placement in placements:
794
+ if len(placement) <= 1:
620
795
  continue # flat attach == final placement (first chain link).
621
- into = m["into"]
622
- value = el.pop(into, None)
796
+ value = el.pop(placement[-1], None)
623
797
  target: Any = el
624
- for token in rel[:-1]:
798
+ for token in placement[:-1]:
625
799
  target = target.get(token) if isinstance(target, dict) else None
626
800
  if target is None:
627
801
  break
628
802
  if isinstance(target, dict):
629
- target[rel[-1]] = value
803
+ target[placement[-1]] = value
630
804
  return el
631
805
 
632
806
 
@@ -51,7 +51,11 @@ from .per_key_cursor import (
51
51
  encode_per_key_cursor,
52
52
  serialize_contract_key,
53
53
  )
54
- from .behavior_exec import collect_transaction_items, execute_read_component
54
+ from .behavior_exec import (
55
+ collect_transaction_items,
56
+ execute_read_component,
57
+ relation_paths_from_output,
58
+ )
55
59
  from .templates import (
56
60
  key_condition_templates,
57
61
  render_key_expr_to_template,
@@ -2064,13 +2068,23 @@ class GraphDDBRuntime:
2064
2068
  f"{query_id}: query needs {len(body)} operations, exceeds "
2065
2069
  f"max_operations {self._limits.max_operations}"
2066
2070
  )
2071
+ # Relation-traversal DEPTH is the longest relation path (id-scheme-INDEPENDENT
2072
+ # — issue #350): a GENUINELY declarative read (`compileBehaviors`) has
2073
+ # recorder-scan ids (`n0`/`n1`) that no longer encode the hop count, so the
2074
+ # depth is the longest `relation_path` derived from the `output` Φ structure +
2075
+ # the `map.parent`/`into` chain (`relation_paths_from_output`). The legacy
2076
+ # dotted-id count is the fallback for a node absent from that map (defensive).
2077
+ rel_paths = relation_paths_from_output(component)
2067
2078
  depth = 0
2068
2079
  for node in body:
2069
2080
  node_id = str(node.get("id", ""))
2070
2081
  if node_id in ("", "root"):
2071
2082
  continue
2072
- # Each non-`items` segment is one relation hop.
2073
- depth = max(depth, sum(1 for t in node_id.split(".") if t != "items"))
2083
+ if node_id in rel_paths:
2084
+ depth = max(depth, len(rel_paths[node_id]))
2085
+ else:
2086
+ # Each non-`items` segment is one relation hop (legacy imperative ids).
2087
+ depth = max(depth, sum(1 for t in node_id.split(".") if t != "items"))
2074
2088
  if depth > self._limits.max_depth:
2075
2089
  raise LimitExceededError(
2076
2090
  f"{query_id}: relation traversal depth {depth} exceeds max_depth "
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 1.0.9
3
+ Version: 1.1.0
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
7
7
  Description-Content-Type: text/markdown
8
8
  Requires-Dist: boto3>=1.26
9
- Requires-Dist: behavior-contracts==0.7.3
9
+ Requires-Dist: behavior-contracts==0.8.2
10
10
  Provides-Extra: test
11
11
  Requires-Dist: pytest>=7.0; extra == "test"
12
12
 
@@ -1,5 +1,5 @@
1
1
  boto3>=1.26
2
- behavior-contracts==0.7.3
2
+ behavior-contracts==0.8.2
3
3
 
4
4
  [test]
5
5
  pytest>=7.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphddb-runtime"
7
- version = "1.0.9"
7
+ version = "1.1.0"
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"
@@ -14,7 +14,7 @@ license = { text = "MIT" }
14
14
  # canonicalisation) are delegated to the extracted shared package
15
15
  # ``behavior-contracts`` (import name ``behavior_contracts``). The runtime
16
16
  # imports it unconditionally, so it is a HARD dependency.
17
- dependencies = ["boto3>=1.26", "behavior-contracts==0.7.3"]
17
+ dependencies = ["boto3>=1.26", "behavior-contracts==0.8.2"]
18
18
 
19
19
  [project.optional-dependencies]
20
20
  test = ["pytest>=7.0"]
@@ -404,5 +404,26 @@ def test_micro_mock_graphddb_cell_runs_without_io():
404
404
  def test_micro_fixtures_match_ts_shapes():
405
405
  """The Python micro fixtures mirror the TS MICRO_FIXTURES (attr/row counts)."""
406
406
  assert LIST_ROWS == 20
407
- # USER_ITEM carries the 6 attributes the TS fixture does.
408
- assert set(USER_ITEM.keys()) == {"PK", "SK", "userId", "name", "email", "status"}
407
+ # USER_ITEM is the superset row the TS fixture (microbench/mock-transport.ts)
408
+ # carries: the 6 base profile attributes plus the pattern point-read scalars,
409
+ # the embeddedRefs id-list, and the inline snapshot/materialized-view lists.
410
+ assert set(USER_ITEM.keys()) == {
411
+ "PK",
412
+ "SK",
413
+ "userId",
414
+ "name",
415
+ "email",
416
+ "status",
417
+ "threadId",
418
+ "postId",
419
+ "groupId",
420
+ "authorId",
421
+ "docId",
422
+ "version",
423
+ "title",
424
+ "displayName",
425
+ "postCount",
426
+ "tagRefs",
427
+ "latestPosts",
428
+ "recentPosts",
429
+ }
@@ -82,16 +82,34 @@ def _runtime(client, operations_path=conftest.OPERATIONS_PATH):
82
82
  )
83
83
 
84
84
 
85
- def _ops_with_policy(tmp_path, policy, node_ids=("members.items",)):
86
- """The generated fixture document with `policy` injected on relation nodes
85
+ def _ops_with_policy(tmp_path, policy, relations=("members",)):
86
+ """The generated fixture document with `policy` injected on relation QUERY nodes
87
87
  of GroupAccessRead__get (graphddb has no authoring surface for non-default
88
- policy yet — the injected field proves the componentRef pass-through)."""
88
+ policy yet — the injected field proves the componentRef pass-through).
89
+
90
+ The target QUERY node is resolved by RELATION PROPERTY via the component
91
+ `output` Φ merge, THROUGH the CondNode gate (issue #350: each relation is
92
+ authored as `when(...)`, so its output ref is the Φ cond whose `then` refs the
93
+ gated Query — the node that carries a `policy`). Id-scheme independent."""
89
94
  with open(conftest.OPERATIONS_PATH, encoding="utf-8") as fh:
90
95
  ops = json.load(fh)
91
96
  doc = copy.deepcopy(ops)
92
97
  comp = next(c for c in doc["components"] if c["name"] == "GroupAccessRead__get")
98
+ by_id = {n["id"]: n for n in comp["body"]}
99
+
100
+ def _query_id(node_id):
101
+ node = by_id.get(node_id)
102
+ if node is not None and "cond" in node:
103
+ then = node["cond"].get("then", {})
104
+ then_ref = then.get("ref") if isinstance(then, dict) else None
105
+ if then_ref:
106
+ return _query_id(then_ref[0])
107
+ return node_id
108
+
109
+ out_obj = comp["output"]["obj"]
110
+ target_ids = {_query_id(out_obj[rel]["ref"][0]) for rel in relations}
93
111
  for node in comp["body"]:
94
- if node["id"] in node_ids:
112
+ if node["id"] in target_ids:
95
113
  node["policy"] = policy
96
114
  path = tmp_path / "operations.json"
97
115
  path.write_text(json.dumps(doc), encoding="utf-8")
@@ -120,31 +138,39 @@ def test_explicit_fail_policy_matches_default(tmp_path):
120
138
  assert "DynamoDB operation failed" in str(ei.value)
121
139
 
122
140
 
123
- def test_continue_policy_skips_failed_branch_and_runs_siblings(tmp_path):
124
- # continue Kind (scp-error.md §continue): the failed members query is
125
- # swallowed into Skip propagation (unproduced connection), the INDEPENDENT
126
- # permissions sibling still executes and resolves.
127
- client = _Client()
128
- rt = _runtime(client, _ops_with_policy(tmp_path, "continue"))
129
- result = rt.execute_query("GroupAccessRead__get", {"groupId": "g1"})
130
- assert result["groupId"] == "g1"
131
- assert result["members"] == {"items": [], "cursor": None} # unproduced
132
- perms = result["permissions"]["items"]
133
- assert [p["resource"] for p in perms] == ["doc"]
134
- # Both relation queries were issued (the failure did not abort the stage).
135
- assert sum(1 for m, _ in client.calls if m == "query") == 2
136
-
137
-
138
- def test_continue_policy_on_all_relations(tmp_path):
139
- client = _Client(fail_all_queries=True)
140
- rt = _runtime(
141
- client,
142
- _ops_with_policy(tmp_path, "continue", ("members.items", "permissions.items")),
143
- )
141
+ class _NullGroupIdClient(_Client):
142
+ """Root GetItem returns a row whose `groupId` is null → the relation gates
143
+ (`ne(root.groupId, null)`) are false, so no relation Query is dispatched."""
144
+
145
+ def get_item(self, **kwargs):
146
+ self.calls.append(("get_item", kwargs))
147
+ return {
148
+ "Item": {
149
+ "PK": {"S": "GROUP#g1"},
150
+ "SK": {"S": "META"},
151
+ "groupId": {"NULL": True},
152
+ "name": {"S": "Engineering"},
153
+ "description": {"S": "desc"},
154
+ "createdAt": {"S": "2020-01-02T03:04:05.000Z"},
155
+ }
156
+ }
157
+
158
+
159
+ def test_gate_folds_absent_parent_relation_to_empty_connection(tmp_path):
160
+ # Issue #350 course-correction: each relation is authored as
161
+ # `when(ne(root.<sourceField>, null), () => Query, () => empty)`, so the
162
+ # empty-connection fallback is the cond's ELSE branch (a DECLARATIVE default),
163
+ # NOT an injected `continue` policy. When the parent source field (`groupId`) is
164
+ # null, bc skips the gated Query and folds the relation to `{items:[],
165
+ # cursor:None}` — the same unproduced-connection shape the old continue policy
166
+ # produced, now expressed in SCP.
167
+ client = _NullGroupIdClient()
168
+ rt = _runtime(client) # no injected policy — the gate is the mechanism
144
169
  result = rt.execute_query("GroupAccessRead__get", {"groupId": "g1"})
145
- assert result["groupId"] == "g1"
146
170
  assert result["members"] == {"items": [], "cursor": None}
147
171
  assert result["permissions"] == {"items": [], "cursor": None}
172
+ # NEITHER relation Query was dispatched (the gate skipped both).
173
+ assert sum(1 for m, _ in client.calls if m == "query") == 0
148
174
 
149
175
 
150
176
  def test_unknown_policy_kind_fails_closed(tmp_path):
@@ -1086,11 +1086,18 @@ def test_multi_operation_query_is_now_supported(tmp_path):
1086
1086
  root_component = next(
1087
1087
  c for c in ops["components"] if c["name"] == "UserByEmailRead__get"
1088
1088
  )
1089
+ # This hand-built multi component uses the LEGACY dotted-id scheme (`root` /
1090
+ # `groups.items`) for its own wiring (`{ref:["root",...]}`, output `{ref:["root"]}`).
1091
+ # The production `UserByEmailRead__get` is now GENUINELY declarative (issue #350),
1092
+ # so its root node id is `n0`; normalize the COPIED root node's id back to `root`
1093
+ # so this fixture stays internally consistent with its legacy-scheme references.
1094
+ _root_node = dict(root_component["body"][0])
1095
+ _root_node["id"] = "root"
1089
1096
  ops["components"].append({
1090
1097
  "name": "multi",
1091
1098
  "inputPorts": {"email": {"type": "string", "required": True}},
1092
1099
  "body": [
1093
- dict(root_component["body"][0]),
1100
+ _root_node,
1094
1101
  {
1095
1102
  "id": "groups.items",
1096
1103
  "component": "Query",