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.
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/PKG-INFO +2 -2
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/behavior_exec.py +199 -25
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/runtime.py +17 -3
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime.egg-info/PKG-INFO +2 -2
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime.egg-info/requires.txt +1 -1
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/pyproject.toml +2 -2
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_crosslang_python_cells.py +23 -2
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_error_policy.py +52 -26
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_unit.py +8 -1
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/README.md +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/__init__.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/async_runtime.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/batch.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/concurrency.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/cursor.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/errors.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/filters.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/handler_registry.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/hydration.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/limits.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/middleware.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/per_key_cursor.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/templates.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime/transactions.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime.egg-info/SOURCES.txt +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime.egg-info/top_level.txt +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/setup.cfg +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_atomic_add_command.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_concurrency.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_contract_runtime.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_description.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_description_followups.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_description_jsdoc.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_generated_module.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_guard_eval.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_handler_registry.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_batch_write.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_binding_contract.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_command.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_compose.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_contract.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_edge_derive.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_edge_write.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_events.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_maintain.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_middleware.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_referential.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_relations.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_unique.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_maintain.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_middleware.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_relations.py +0 -0
- {graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_spec_version_guard.py +0 -0
- {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
|
|
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.
|
|
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":
|
|
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
|
|
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,
|
|
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,
|
|
785
|
+
element: Any, placements: List[Tuple[str, List[str]]]
|
|
601
786
|
) -> Any:
|
|
602
|
-
"""Re-nest one augmented connection element: for each chain map
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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
|
-
|
|
622
|
-
value = el.pop(into, None)
|
|
796
|
+
value = el.pop(placement[-1], None)
|
|
623
797
|
target: Any = el
|
|
624
|
-
for token in
|
|
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[
|
|
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
|
|
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
|
-
|
|
2073
|
-
|
|
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
|
|
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.
|
|
9
|
+
Requires-Dist: behavior-contracts==0.8.2
|
|
10
10
|
Provides-Extra: test
|
|
11
11
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
12
12
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphddb-runtime"
|
|
7
|
-
version = "1.0
|
|
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.
|
|
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
|
|
408
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
1100
|
+
_root_node,
|
|
1094
1101
|
{
|
|
1095
1102
|
"id": "groups.items",
|
|
1096
1103
|
"component": "Query",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/graphddb_runtime.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphddb_runtime-1.0.9 → graphddb_runtime-1.1.0}/tests/test_integration_binding_contract.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|