graphddb-runtime 0.7.0__tar.gz → 0.7.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/PKG-INFO +1 -1
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/__init__.py +2 -1
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/relations.py +80 -1
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/runtime.py +84 -1
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime.egg-info/PKG-INFO +1 -1
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime.egg-info/SOURCES.txt +1 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/pyproject.toml +1 -1
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_integration_relations.py +65 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_relations.py +137 -0
- graphddb_runtime-0.7.2/tests/test_spec_version_guard.py +95 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/README.md +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/async_runtime.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/batch.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/concurrency.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/cursor.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/errors.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/filters.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/hydration.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/limits.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/middleware.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/per_key_cursor.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/templates.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime/transactions.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime.egg-info/requires.txt +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/graphddb_runtime.egg-info/top_level.txt +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/setup.cfg +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_concurrency.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_contract_runtime.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_description.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_description_followups.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_description_jsdoc.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_integration.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_integration_command.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_integration_compose.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_integration_contract.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_integration_edge_derive.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_integration_edge_write.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_integration_events.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_integration_maintain.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_integration_middleware.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_integration_referential.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_integration_unique.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_maintain.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_middleware.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_ttl.py +0 -0
- {graphddb_runtime-0.7.0 → graphddb_runtime-0.7.2}/tests/test_unit.py +0 -0
|
@@ -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
|
-
|
|
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 {
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphddb-runtime"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.2"
|
|
8
8
|
description = "Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -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)
|
|
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-0.7.0 → graphddb_runtime-0.7.2}/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
|
|
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
|