graphddb-runtime 0.6.0__tar.gz → 0.7.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 (47) hide show
  1. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/PKG-INFO +1 -1
  2. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/__init__.py +2 -1
  3. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/relations.py +80 -1
  4. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/runtime.py +84 -1
  5. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime.egg-info/PKG-INFO +1 -1
  6. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime.egg-info/SOURCES.txt +1 -0
  7. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/pyproject.toml +1 -1
  8. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_integration_relations.py +65 -0
  9. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_relations.py +137 -0
  10. graphddb_runtime-0.7.1/tests/test_spec_version_guard.py +95 -0
  11. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/README.md +0 -0
  12. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/async_runtime.py +0 -0
  13. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/batch.py +0 -0
  14. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/concurrency.py +0 -0
  15. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/cursor.py +0 -0
  16. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/errors.py +0 -0
  17. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/filters.py +0 -0
  18. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/hydration.py +0 -0
  19. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/limits.py +0 -0
  20. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/middleware.py +0 -0
  21. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/per_key_cursor.py +0 -0
  22. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/templates.py +0 -0
  23. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime/transactions.py +0 -0
  24. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
  25. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime.egg-info/requires.txt +0 -0
  26. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/graphddb_runtime.egg-info/top_level.txt +0 -0
  27. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/setup.cfg +0 -0
  28. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_concurrency.py +0 -0
  29. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_contract_runtime.py +0 -0
  30. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_description.py +0 -0
  31. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_description_followups.py +0 -0
  32. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_description_jsdoc.py +0 -0
  33. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_integration.py +0 -0
  34. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_integration_command.py +0 -0
  35. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_integration_compose.py +0 -0
  36. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_integration_contract.py +0 -0
  37. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_integration_edge_derive.py +0 -0
  38. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_integration_edge_write.py +0 -0
  39. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_integration_events.py +0 -0
  40. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_integration_maintain.py +0 -0
  41. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_integration_middleware.py +0 -0
  42. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_integration_referential.py +0 -0
  43. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_integration_unique.py +0 -0
  44. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_maintain.py +0 -0
  45. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_middleware.py +0 -0
  46. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_ttl.py +0 -0
  47. {graphddb_runtime-0.6.0 → graphddb_runtime-0.7.1}/tests/test_unit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -41,10 +41,11 @@ from .middleware import (
41
41
  WriteCtx,
42
42
  WriteRuntime,
43
43
  )
44
- from .runtime import GraphDDBRuntime
44
+ from .runtime import SPEC_VERSION_SUPPORTED, GraphDDBRuntime
45
45
 
46
46
  __all__ = [
47
47
  "GraphDDBRuntime",
48
+ "SPEC_VERSION_SUPPORTED",
48
49
  "AsyncGraphDDBRuntime",
49
50
  "RuntimeLimits",
50
51
  # middleware / hooks (issue #50 / #140 — Python parity)
@@ -132,7 +132,12 @@ class RelationAssembler:
132
132
  return
133
133
 
134
134
  if op["type"] == "BatchGetItem":
135
- self._apply_batch_get(parents, op, write_key)
135
+ if op.get("sourceList"):
136
+ # The list fan-out binding form (issue #208, B案): a `refs`
137
+ # relation resolved from an inline parent-row id-list.
138
+ self._apply_refs(parents, op, write_key)
139
+ else:
140
+ self._apply_batch_get(parents, op, write_key)
136
141
  else: # Query (hasMany)
137
142
  self._apply_query(parents, op, write_key)
138
143
 
@@ -167,6 +172,80 @@ class RelationAssembler:
167
172
  continue
168
173
  parent[write_key] = key_to_item.get(self._key_for(op, sv))
169
174
 
175
+ # ── refs: an inline parent-row id-list → ONE deduped BatchGetItem ───────────
176
+
177
+ def _apply_refs(
178
+ self,
179
+ parents: List[Dict[str, Any]],
180
+ op: Dict[str, Any],
181
+ write_key: str,
182
+ ) -> None:
183
+ """Resolve a ``refs`` fan-out op (issue #208 B案; #197 read side).
184
+
185
+ The op's ``sourceList`` names the parent LIST attribute (``from``) whose
186
+ elements each carry the reference key (``key`` — always equal to the op's
187
+ ``sourceField``, the ``{result.<sourceField>}`` token). Semantics mirror
188
+ the TS in-process ``fetchRefsList`` byte-for-byte:
189
+
190
+ - an object element binds ``element[key]``; a bare scalar element binds
191
+ itself; a null/missing ref is skipped;
192
+ - ALL parents' element keys fan into ONE deduped BatchGet (the injected
193
+ ``run_batch_get`` already chunks at 100 and retries UnprocessedKeys);
194
+ - each parent receives a ``{"items": [...], "cursor": None}`` connection —
195
+ the resolved child bodies in **first-seen element order**, deduped,
196
+ with missing bodies dropped (a dangling ref cannot be hydrated);
197
+ - a parent whose list is absent / not a list resolves to an empty
198
+ connection.
199
+ """
200
+ source_field = op["sourceField"]
201
+ from_attr = op["sourceList"]["from"]
202
+ element_key = op["sourceList"].get("key", source_field)
203
+
204
+ def element_values(parent: Dict[str, Any]) -> List[Any]:
205
+ raw = parent.get(from_attr)
206
+ elements = raw if isinstance(raw, list) else []
207
+ values: List[Any] = []
208
+ for element in elements:
209
+ value = (
210
+ element.get(element_key)
211
+ if isinstance(element, dict)
212
+ else element
213
+ )
214
+ if value is None:
215
+ continue
216
+ values.append(value)
217
+ return values
218
+
219
+ # One deduped BatchGet across ALL parents' element keys.
220
+ source_values: List[Dict[str, Any]] = []
221
+ seen: set = set()
222
+ for parent in parents:
223
+ for value in element_values(parent):
224
+ sv = {source_field: value}
225
+ marker = self._key_for(op, sv)
226
+ if marker in seen:
227
+ continue
228
+ seen.add(marker)
229
+ source_values.append(sv)
230
+
231
+ key_to_item = (
232
+ self._run_batch_get(op, source_values) if source_values else {}
233
+ )
234
+
235
+ for parent in parents:
236
+ items: List[Any] = []
237
+ seen_local: set = set()
238
+ for value in element_values(parent):
239
+ sv = {source_field: value}
240
+ marker = self._key_for(op, sv)
241
+ if marker in seen_local:
242
+ continue
243
+ seen_local.add(marker)
244
+ item = key_to_item.get(marker)
245
+ if item is not None:
246
+ items.append(item)
247
+ parent[write_key] = {"items": items, "cursor": None}
248
+
170
249
  # ── hasMany: a per-parent Query ─────────────────────────────────────────────
171
250
 
172
251
  def _apply_query(
@@ -48,7 +48,7 @@ from .per_key_cursor import (
48
48
  encode_per_key_cursor,
49
49
  serialize_contract_key,
50
50
  )
51
- from .relations import RelationAssembler
51
+ from .relations import RelationAssembler, collect_parents, parse_result_path
52
52
  from .templates import (
53
53
  resolve_template,
54
54
  validate_params,
@@ -56,6 +56,54 @@ from .templates import (
56
56
  from .transactions import MAX_TRANSACT_ITEMS, TransactionExpander
57
57
  from .transactions import _when_holds as _tx_when_holds
58
58
 
59
+ #: The operation-IR (spec) version this runtime implements — the newest
60
+ #: ``manifest.json`` / ``operations.json`` document version whose vocabulary it
61
+ #: fully understands. See :func:`_validate_spec_version` for the compatibility
62
+ #: rule.
63
+ SPEC_VERSION_SUPPORTED = "1.1"
64
+
65
+
66
+ def _validate_spec_version(document: Mapping[str, Any], label: str) -> None:
67
+ """Loudly reject a document this runtime cannot faithfully execute (#208).
68
+
69
+ The IR is a **stable execution contract**: its op vocabulary is closed over
70
+ the physical DynamoDB API, but the *binding grammar* grows occasionally
71
+ (e.g. the 1.1 ``sourceList`` list fan-out, #208 B案). A runtime handed a
72
+ document NEWER than it understands could silently mis-execute the unknown
73
+ binding forms (a pre-1.1 runtime resolves a ``refs`` fan-out to ``None``
74
+ without any error), so version skew must fail loudly, never silently.
75
+
76
+ Compatibility rule (``<major>.<minor>`` vs :data:`SPEC_VERSION_SUPPORTED`):
77
+
78
+ - **major** must equal the supported major — a major bump means the
79
+ existing vocabulary itself changed shape;
80
+ - **minor** must be **<=** the supported minor — minors are additive, so
81
+ every OLDER document is fully understood (backward compatible), while a
82
+ NEWER one may carry vocabulary this runtime does not implement
83
+ (forward-rejected);
84
+ - a missing / malformed version is rejected (an unversioned document
85
+ cannot be trusted to match any vocabulary).
86
+ """
87
+ version = document.get("version")
88
+ if not isinstance(version, str) or not re.fullmatch(r"\d+\.\d+", version):
89
+ raise GraphDDBError(
90
+ f"{label}: missing or malformed spec version {version!r} — expected "
91
+ f"'<major>.<minor>' (this runtime supports {SPEC_VERSION_SUPPORTED}). "
92
+ f"Regenerate the document with a matching graphddb generator."
93
+ )
94
+ major, minor = (int(part) for part in version.split("."))
95
+ supported_major, supported_minor = (
96
+ int(part) for part in SPEC_VERSION_SUPPORTED.split(".")
97
+ )
98
+ if major != supported_major or minor > supported_minor:
99
+ raise GraphDDBError(
100
+ f"{label}: document spec version '{version}' is not supported by this "
101
+ f"runtime (supports major {supported_major}, minor <= {supported_minor}). "
102
+ f"A newer document may carry binding forms this runtime would silently "
103
+ f"mis-execute; upgrade graphddb_runtime or regenerate the document with "
104
+ f"a matching generator version."
105
+ )
106
+
59
107
  try: # pragma: no cover - exercised indirectly
60
108
  from botocore.exceptions import BotoCoreError, ClientError
61
109
 
@@ -168,6 +216,12 @@ class GraphDDBRuntime:
168
216
  with open(operations_path, "r", encoding="utf-8") as fh:
169
217
  self._operations: Dict[str, Any] = json.load(fh)
170
218
 
219
+ # The IR is a stable execution contract (#208): refuse a document whose
220
+ # version this runtime does not fully implement, LOUDLY at construction
221
+ # (never a silent per-operation mis-execution).
222
+ _validate_spec_version(self._manifest, "manifest.json")
223
+ _validate_spec_version(self._operations, "operations.json")
224
+
171
225
  self._queries: Dict[str, Any] = self._operations.get("queries", {})
172
226
  self._commands: Dict[str, Any] = self._operations.get("commands", {})
173
227
  self._transactions: Dict[str, Any] = self._operations.get("transactions", {})
@@ -360,8 +414,33 @@ class GraphDDBRuntime:
360
414
  key_for=self._relation_key_marker,
361
415
  )
362
416
  self._assemble_relations(spec, operations, root, assembler)
417
+ self._strip_implicit_sources(operations, root)
363
418
  return root
364
419
 
420
+ @staticmethod
421
+ def _strip_implicit_sources(
422
+ operations: List[Dict[str, Any]], root: Any
423
+ ) -> None:
424
+ """Drop parent attributes projected solely to drive a fan-out (#208 B案).
425
+
426
+ A ``refs`` fan-out op whose ``sourceList.implicit`` is set reads a parent
427
+ LIST attribute the query's select did not ask for — the planner projected
428
+ it onto the parent operation only so the fan-out could bind its keys
429
+ (mirroring the TS implicit relation-source projection, which the TS
430
+ hydrator strips from the assembled result). Delete it from every parent
431
+ node AFTER all relation operations are applied, so the visible result
432
+ carries exactly the selected fields on both runtimes.
433
+ """
434
+ for op in operations:
435
+ source_list = op.get("sourceList")
436
+ if not source_list or not source_list.get("implicit"):
437
+ continue
438
+ parent_tokens, _write_key, _is_connection = parse_result_path(
439
+ op["resultPath"]
440
+ )
441
+ for parent in collect_parents(root, parent_tokens):
442
+ parent.pop(source_list["from"], None)
443
+
365
444
  def _assemble_relations(
366
445
  self,
367
446
  spec: Mapping[str, Any],
@@ -1644,6 +1723,10 @@ class GraphDDBRuntime:
1644
1723
  resolved["filter"] = op["filter"]
1645
1724
  if op.get("sourceField"):
1646
1725
  resolved["sourceField"] = op["sourceField"]
1726
+ if op.get("sourceList"):
1727
+ # The list fan-out binding form (issue #208 B案) is resolved from
1728
+ # prior results only at execution time; carry it verbatim.
1729
+ resolved["sourceList"] = dict(op["sourceList"])
1647
1730
  resolved_ops.append(resolved)
1648
1731
 
1649
1732
  return {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -40,5 +40,6 @@ tests/test_integration_unique.py
40
40
  tests/test_maintain.py
41
41
  tests/test_middleware.py
42
42
  tests/test_relations.py
43
+ tests/test_spec_version_guard.py
43
44
  tests/test_ttl.py
44
45
  tests/test_unit.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphddb-runtime"
7
- version = "0.6.0"
7
+ version = "0.7.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"
@@ -177,6 +177,29 @@ def seeded(raw_client):
177
177
  "grantedAt": {"S": "2022-01-01T00:00:00.000Z"},
178
178
  })
179
179
 
180
+ # #208 (B案) refs fixtures: tag bodies + articles carrying inline id-lists.
181
+ for tid, label in [("ts", "TypeScript"), ("py", "Python")]:
182
+ put({
183
+ "PK": {"S": f"TAG#{tid}"}, "SK": {"S": "META"},
184
+ "tagId": {"S": tid}, "label": {"S": label},
185
+ })
186
+ put({
187
+ "PK": {"S": "ARTICLE#a1"}, "SK": {"S": "META"},
188
+ "articleId": {"S": "a1"}, "title": {"S": "Refs!"},
189
+ # duplicate ref (ts×2) + a dangling ref (ghost) — dedup / drop semantics.
190
+ "tagRefs": {"L": [
191
+ {"M": {"tagId": {"S": "py"}}},
192
+ {"M": {"tagId": {"S": "ts"}}},
193
+ {"M": {"tagId": {"S": "ts"}}},
194
+ {"M": {"tagId": {"S": "ghost"}}},
195
+ ]},
196
+ })
197
+ put({
198
+ "PK": {"S": "ARTICLE#a2"}, "SK": {"S": "META"},
199
+ "articleId": {"S": "a2"}, "title": {"S": "No tags"},
200
+ "tagRefs": {"L": []},
201
+ })
202
+
180
203
  yield client
181
204
  client.delete_table(TableName=TABLE)
182
205
 
@@ -309,3 +332,45 @@ def test_explain_lists_operations_without_touching_dynamodb(seeded):
309
332
  assert plan["operations"][0]["keyCondition"]["GSI1PK"] == "EMAIL#alice@example.com"
310
333
  assert plan["operations"][2]["type"] == "BatchGetItem"
311
334
  assert spy.counts == {}
335
+
336
+
337
+ # ── #208 (B案): refs — the inline id-list fan-out through the generated bridge ──
338
+
339
+
340
+ def test_refs_fan_out_resolves_inline_id_list_in_one_batch_get(env):
341
+ bridge = _load_generated_package()
342
+ repo = bridge.ArticleRepository(env["runtime"])
343
+ env["spy"].reset()
344
+
345
+ out = repo.get_article_with_tags(article_id="a1")
346
+ assert out is not None
347
+ assert out["articleId"] == "a1"
348
+ assert out["title"] == "Refs!"
349
+ # First-seen element order, deduped (ts once), dangling ref dropped,
350
+ # cursor always None — byte-matching the TS in-process refs semantics.
351
+ assert out["tags"] == {
352
+ "items": [
353
+ {"tagId": "py", "label": "Python"},
354
+ {"tagId": "ts", "label": "TypeScript"},
355
+ ],
356
+ "cursor": None,
357
+ }
358
+ # The source list attribute was projected ONLY to drive the fan-out
359
+ # (sourceList.implicit) and must be stripped from the assembled result.
360
+ assert "tagRefs" not in out
361
+ # Physical cost: 1 parent GetItem + ONE deduped BatchGet — never per-ref reads.
362
+ assert env["spy"].counts.get("batch_get_item", 0) == 1
363
+ assert env["spy"].counts.get("get_item", 0) == 1
364
+
365
+
366
+ def test_refs_empty_list_resolves_empty_connection_without_batch_get(env):
367
+ bridge = _load_generated_package()
368
+ repo = bridge.ArticleRepository(env["runtime"])
369
+ env["spy"].reset()
370
+
371
+ out = repo.get_article_with_tags(article_id="a2")
372
+ assert out is not None
373
+ assert out["tags"] == {"items": [], "cursor": None}
374
+ assert "tagRefs" not in out
375
+ # No refs to resolve → no BatchGet issued at all.
376
+ assert env["spy"].counts.get("batch_get_item", 0) == 0
@@ -474,3 +474,140 @@ def test_resolve_entity_get_item_uses_sk_predicate():
474
474
  # SK 'META' uniquely selects GroupModel (createdAt:datetime).
475
475
  assert meta["fields"].get("createdAt", {}).get("format") == "datetime"
476
476
  assert "name" in meta["fields"]
477
+
478
+
479
+ # ── refs: the list fan-out binding form (issue #208 B案 / #197 read side) ───────
480
+
481
+
482
+ def _refs_op(**extra):
483
+ op = {
484
+ "type": "BatchGetItem",
485
+ "keyCondition": {"PK": "TAG#{result.tagId}", "SK": "META"},
486
+ "resultPath": "$.tags.items",
487
+ "sourceField": "tagId",
488
+ "sourceList": {"from": "tagRefs", "key": "tagId"},
489
+ }
490
+ op.update(extra)
491
+ return op
492
+
493
+
494
+ def _refs_assembler(store, batches):
495
+ """An assembler whose BatchGet serves child bodies from ``store`` (tagId → item)."""
496
+
497
+ def run_batch_get(op, source_values):
498
+ batches.append([sv["tagId"] for sv in source_values])
499
+ out = {}
500
+ for sv in source_values:
501
+ item = store.get(sv["tagId"])
502
+ if item is not None:
503
+ out[serialize_key({"PK": f"TAG#{sv['tagId']}"})] = item
504
+ return out
505
+
506
+ return RelationAssembler(
507
+ run_query=lambda op, sv: {"items": [], "cursor": None},
508
+ run_batch_get=run_batch_get,
509
+ key_for=lambda op, sv: serialize_key({"PK": f"TAG#{sv['tagId']}"}),
510
+ )
511
+
512
+
513
+ def test_assembler_refs_fans_out_one_deduped_batch_first_seen_order():
514
+ batches = []
515
+ store = {
516
+ "a": {"tagId": "a", "name": "Alpha"},
517
+ "b": {"tagId": "b", "name": "Beta"},
518
+ }
519
+ asm = _refs_assembler(store, batches)
520
+ root = {
521
+ "postId": "p1",
522
+ # duplicate ref + a null ref + a missing body ('ghost') interleaved.
523
+ "tagRefs": [
524
+ {"tagId": "b"},
525
+ {"tagId": "a"},
526
+ {"tagId": "b"},
527
+ {"tagId": None},
528
+ {"tagId": "ghost"},
529
+ ],
530
+ }
531
+ asm.apply(root, _refs_op())
532
+ # ONE batch, deduped, in first-seen order (null refs skipped).
533
+ assert batches == [["b", "a", "ghost"]]
534
+ # Items in first-seen element order, deduped, missing body dropped, cursor None.
535
+ assert root["tags"] == {
536
+ "items": [
537
+ {"tagId": "b", "name": "Beta"},
538
+ {"tagId": "a", "name": "Alpha"},
539
+ ],
540
+ "cursor": None,
541
+ }
542
+
543
+
544
+ def test_assembler_refs_missing_or_non_list_source_resolves_empty_connection():
545
+ batches = []
546
+ asm = _refs_assembler({}, batches)
547
+ root_missing = {"postId": "p1"}
548
+ asm.apply(root_missing, _refs_op())
549
+ assert root_missing["tags"] == {"items": [], "cursor": None}
550
+
551
+ root_scalar = {"postId": "p2", "tagRefs": "not-a-list"}
552
+ asm.apply(root_scalar, _refs_op())
553
+ assert root_scalar["tags"] == {"items": [], "cursor": None}
554
+
555
+ # No source values at all → no BatchGet issued.
556
+ assert batches == []
557
+
558
+
559
+ def test_assembler_refs_bare_scalar_elements_bind_themselves():
560
+ batches = []
561
+ store = {"x": {"tagId": "x", "name": "X"}}
562
+ asm = _refs_assembler(store, batches)
563
+ root = {"postId": "p1", "tagRefs": ["x", None, "x"]}
564
+ asm.apply(root, _refs_op())
565
+ assert batches == [["x"]]
566
+ assert root["tags"]["items"] == [{"tagId": "x", "name": "X"}]
567
+
568
+
569
+ def test_assembler_refs_multiple_parents_share_one_batch():
570
+ batches = []
571
+ store = {
572
+ "a": {"tagId": "a", "name": "Alpha"},
573
+ "b": {"tagId": "b", "name": "Beta"},
574
+ }
575
+ asm = _refs_assembler(store, batches)
576
+ root = {
577
+ "posts": {
578
+ "items": [
579
+ {"postId": "p1", "tagRefs": [{"tagId": "a"}, {"tagId": "b"}]},
580
+ {"postId": "p2", "tagRefs": [{"tagId": "b"}]},
581
+ {"postId": "p3", "tagRefs": []},
582
+ ],
583
+ "cursor": None,
584
+ }
585
+ }
586
+ asm.apply(root, _refs_op(resultPath="$.posts.items.tags.items"))
587
+ # The fan-out across ALL parents is one deduped batch.
588
+ assert batches == [["a", "b"]]
589
+ items = root["posts"]["items"]
590
+ assert [t["tagId"] for t in items[0]["tags"]["items"]] == ["a", "b"]
591
+ assert [t["tagId"] for t in items[1]["tags"]["items"]] == ["b"]
592
+ assert items[2]["tags"] == {"items": [], "cursor": None}
593
+
594
+
595
+ def test_strip_implicit_sources_removes_only_implicit_list_attributes():
596
+ root = {
597
+ "postId": "p1",
598
+ "tagRefs": [{"tagId": "a"}],
599
+ "tags": {"items": [{"tagId": "a", "name": "Alpha"}], "cursor": None},
600
+ }
601
+ ops = [
602
+ {"type": "GetItem", "resultPath": "$"},
603
+ _refs_op(sourceList={"from": "tagRefs", "key": "tagId", "implicit": True}),
604
+ ]
605
+ GraphDDBRuntime._strip_implicit_sources(ops, root)
606
+ assert "tagRefs" not in root
607
+ assert root["tags"]["items"] == [{"tagId": "a", "name": "Alpha"}]
608
+
609
+ # A selected (non-implicit) source list stays in the result.
610
+ root2 = {"postId": "p1", "tagRefs": [{"tagId": "a"}], "tags": {"items": [], "cursor": None}}
611
+ ops2 = [{"type": "GetItem", "resultPath": "$"}, _refs_op()]
612
+ GraphDDBRuntime._strip_implicit_sources(ops2, root2)
613
+ assert root2["tagRefs"] == [{"tagId": "a"}]
@@ -0,0 +1,95 @@
1
+ """Spec-version guard tests (issue #208 follow-up).
2
+
3
+ The IR is a stable execution contract: ``GraphDDBRuntime`` must LOUDLY reject a
4
+ ``manifest.json`` / ``operations.json`` whose version it does not fully
5
+ implement, instead of silently mis-executing unknown binding forms (a pre-1.1
6
+ runtime would resolve a 1.1 ``refs`` fan-out to ``None`` without any error).
7
+
8
+ Compatibility rule (pinned here): a ``<major>.<minor>`` document is accepted
9
+ iff its major equals the supported major AND its minor is <= the supported
10
+ minor (minors are additive → older documents are fully understood; newer ones
11
+ are forward-rejected). Missing / malformed versions are rejected.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+
19
+ import pytest
20
+
21
+ import conftest
22
+ from graphddb_runtime import GraphDDBError, GraphDDBRuntime, SPEC_VERSION_SUPPORTED
23
+
24
+
25
+ def _load(path):
26
+ with open(path, "r", encoding="utf-8") as fh:
27
+ return json.load(fh)
28
+
29
+
30
+ def _write(tmp_path, name, doc):
31
+ p = os.path.join(str(tmp_path), name)
32
+ with open(p, "w", encoding="utf-8") as fh:
33
+ json.dump(doc, fh)
34
+ return p
35
+
36
+
37
+ def _runtime(manifest_path, operations_path):
38
+ return GraphDDBRuntime(
39
+ dynamodb_client=object(),
40
+ manifest_path=manifest_path,
41
+ operations_path=operations_path,
42
+ )
43
+
44
+
45
+ def _paths_with_versions(tmp_path, manifest_version, operations_version):
46
+ manifest = _load(conftest.MANIFEST_PATH)
47
+ operations = _load(conftest.OPERATIONS_PATH)
48
+ if manifest_version is None:
49
+ manifest.pop("version", None)
50
+ else:
51
+ manifest["version"] = manifest_version
52
+ if operations_version is None:
53
+ operations.pop("version", None)
54
+ else:
55
+ operations["version"] = operations_version
56
+ return (
57
+ _write(tmp_path, "manifest.json", manifest),
58
+ _write(tmp_path, "operations.json", operations),
59
+ )
60
+
61
+
62
+ def test_supported_version_constant_matches_generated_fixtures():
63
+ # The committed generated fixtures are the runtime's own vocabulary.
64
+ assert _load(conftest.MANIFEST_PATH)["version"] == SPEC_VERSION_SUPPORTED
65
+ assert _load(conftest.OPERATIONS_PATH)["version"] == SPEC_VERSION_SUPPORTED
66
+
67
+
68
+ def test_current_version_documents_are_accepted():
69
+ rt = _runtime(conftest.MANIFEST_PATH, conftest.OPERATIONS_PATH)
70
+ assert rt is not None
71
+
72
+
73
+ def test_older_minor_is_accepted_backward_compatible(tmp_path):
74
+ m, o = _paths_with_versions(tmp_path, "1.0", "1.0")
75
+ assert _runtime(m, o) is not None
76
+
77
+
78
+ @pytest.mark.parametrize("version", ["1.2", "1.99", "2.0", "0.9"])
79
+ def test_newer_minor_or_wrong_major_is_a_loud_reject(tmp_path, version):
80
+ m, o = _paths_with_versions(tmp_path, version, SPEC_VERSION_SUPPORTED)
81
+ with pytest.raises(GraphDDBError, match="not supported"):
82
+ _runtime(m, o)
83
+
84
+
85
+ def test_operations_version_is_validated_independently(tmp_path):
86
+ m, o = _paths_with_versions(tmp_path, SPEC_VERSION_SUPPORTED, "1.2")
87
+ with pytest.raises(GraphDDBError, match="operations.json.*not supported"):
88
+ _runtime(m, o)
89
+
90
+
91
+ @pytest.mark.parametrize("version", [None, "v1", "1", "1.x", 1.1])
92
+ def test_missing_or_malformed_version_is_a_loud_reject(tmp_path, version):
93
+ m, o = _paths_with_versions(tmp_path, version, SPEC_VERSION_SUPPORTED)
94
+ with pytest.raises(GraphDDBError, match="missing or malformed"):
95
+ _runtime(m, o)