graphddb-runtime 0.1.0__py3-none-any.whl
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/__init__.py +58 -0
- graphddb_runtime/async_runtime.py +110 -0
- graphddb_runtime/batch.py +218 -0
- graphddb_runtime/concurrency.py +87 -0
- graphddb_runtime/cursor.py +49 -0
- graphddb_runtime/errors.py +80 -0
- graphddb_runtime/filters.py +194 -0
- graphddb_runtime/hydration.py +75 -0
- graphddb_runtime/limits.py +20 -0
- graphddb_runtime/per_key_cursor.py +105 -0
- graphddb_runtime/relations.py +199 -0
- graphddb_runtime/runtime.py +1674 -0
- graphddb_runtime/templates.py +131 -0
- graphddb_runtime/transactions.py +440 -0
- graphddb_runtime-0.1.0.dist-info/METADATA +160 -0
- graphddb_runtime-0.1.0.dist-info/RECORD +18 -0
- graphddb_runtime-0.1.0.dist-info/WHEEL +5 -0
- graphddb_runtime-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Declarative filter → DynamoDB ``FilterExpression`` compiler (issue #44).
|
|
2
|
+
|
|
3
|
+
Port of the TypeScript ``compileFilterExpression``
|
|
4
|
+
(``src/expression/filter-expression.ts``). The declarative tree carried in a
|
|
5
|
+
query spec's ``filter.declarative`` is JSON-safe (the TS bridge guard rejects
|
|
6
|
+
the non-serializable ``cond`` escape hatch), so only the operator/logical forms
|
|
7
|
+
are handled here.
|
|
8
|
+
|
|
9
|
+
Names are ``#``-aliased columns (reused per distinct column); values are
|
|
10
|
+
``:``-aliased parameters — no literal interpolation. The result is returned in
|
|
11
|
+
the boto3 *client* shape: attribute values are already serialized
|
|
12
|
+
``AttributeValue`` dicts.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
18
|
+
|
|
19
|
+
from boto3.dynamodb.types import TypeSerializer
|
|
20
|
+
|
|
21
|
+
_LOGICAL_KEYS = {"and", "or", "not"}
|
|
22
|
+
_OPERATOR_KEYS = {
|
|
23
|
+
"eq",
|
|
24
|
+
"ne",
|
|
25
|
+
"gt",
|
|
26
|
+
"ge",
|
|
27
|
+
"lt",
|
|
28
|
+
"le",
|
|
29
|
+
"between",
|
|
30
|
+
"in",
|
|
31
|
+
"beginsWith",
|
|
32
|
+
"contains",
|
|
33
|
+
"notContains",
|
|
34
|
+
"attributeExists",
|
|
35
|
+
"attributeType",
|
|
36
|
+
"size",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Ctx:
|
|
41
|
+
def __init__(self, serializer: TypeSerializer) -> None:
|
|
42
|
+
self.names: Dict[str, str] = {}
|
|
43
|
+
self.values: Dict[str, Any] = {}
|
|
44
|
+
self._serializer = serializer
|
|
45
|
+
self._name_n = 0
|
|
46
|
+
self._value_n = 0
|
|
47
|
+
|
|
48
|
+
def name_alias(self, column: str) -> str:
|
|
49
|
+
for alias, col in self.names.items():
|
|
50
|
+
if col == column:
|
|
51
|
+
return alias
|
|
52
|
+
alias = f"#f{self._name_n}"
|
|
53
|
+
self._name_n += 1
|
|
54
|
+
self.names[alias] = column
|
|
55
|
+
return alias
|
|
56
|
+
|
|
57
|
+
def value_alias(self, raw: Any) -> str:
|
|
58
|
+
alias = f":vf{self._value_n}"
|
|
59
|
+
self._value_n += 1
|
|
60
|
+
self.values[alias] = self._serializer.serialize(raw)
|
|
61
|
+
return alias
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_operator_object(value: Any) -> bool:
|
|
65
|
+
if not isinstance(value, dict) or not value:
|
|
66
|
+
return False
|
|
67
|
+
return all(k in _OPERATOR_KEYS for k in value.keys())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_already_wrapped(expr: str) -> bool:
|
|
71
|
+
if not (expr.startswith("(") and expr.endswith(")")):
|
|
72
|
+
return False
|
|
73
|
+
depth = 0
|
|
74
|
+
for i, ch in enumerate(expr):
|
|
75
|
+
if ch == "(":
|
|
76
|
+
depth += 1
|
|
77
|
+
elif ch == ")":
|
|
78
|
+
depth -= 1
|
|
79
|
+
if depth == 0 and i < len(expr) - 1:
|
|
80
|
+
return False
|
|
81
|
+
return depth == 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _wrap(expr: str) -> str:
|
|
85
|
+
if _is_already_wrapped(expr):
|
|
86
|
+
return expr
|
|
87
|
+
if " AND " in expr or " OR " in expr:
|
|
88
|
+
return f"({expr})"
|
|
89
|
+
return expr
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _join_and(clauses: List[str]) -> str:
|
|
93
|
+
if len(clauses) == 1:
|
|
94
|
+
return clauses[0]
|
|
95
|
+
return " AND ".join(_wrap(c) for c in clauses)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _compile_field(ctx: _Ctx, field: str, condition: Any) -> str:
|
|
99
|
+
n = ctx.name_alias(field)
|
|
100
|
+
|
|
101
|
+
if not _is_operator_object(condition):
|
|
102
|
+
return f"{n} = {ctx.value_alias(condition)}"
|
|
103
|
+
|
|
104
|
+
clauses: List[str] = []
|
|
105
|
+
for op, value in condition.items():
|
|
106
|
+
if op == "eq":
|
|
107
|
+
clauses.append(f"{n} = {ctx.value_alias(value)}")
|
|
108
|
+
elif op == "ne":
|
|
109
|
+
clauses.append(f"{n} <> {ctx.value_alias(value)}")
|
|
110
|
+
elif op == "gt":
|
|
111
|
+
clauses.append(f"{n} > {ctx.value_alias(value)}")
|
|
112
|
+
elif op == "ge":
|
|
113
|
+
clauses.append(f"{n} >= {ctx.value_alias(value)}")
|
|
114
|
+
elif op == "lt":
|
|
115
|
+
clauses.append(f"{n} < {ctx.value_alias(value)}")
|
|
116
|
+
elif op == "le":
|
|
117
|
+
clauses.append(f"{n} <= {ctx.value_alias(value)}")
|
|
118
|
+
elif op == "between":
|
|
119
|
+
lo, hi = value
|
|
120
|
+
clauses.append(
|
|
121
|
+
f"{n} BETWEEN {ctx.value_alias(lo)} AND {ctx.value_alias(hi)}"
|
|
122
|
+
)
|
|
123
|
+
elif op == "in":
|
|
124
|
+
aliases = [ctx.value_alias(v) for v in value]
|
|
125
|
+
clauses.append(f"{n} IN ({', '.join(aliases)})")
|
|
126
|
+
elif op == "beginsWith":
|
|
127
|
+
clauses.append(f"begins_with({n}, {ctx.value_alias(value)})")
|
|
128
|
+
elif op == "contains":
|
|
129
|
+
clauses.append(f"contains({n}, {ctx.value_alias(value)})")
|
|
130
|
+
elif op == "notContains":
|
|
131
|
+
clauses.append(f"NOT contains({n}, {ctx.value_alias(value)})")
|
|
132
|
+
elif op == "attributeExists":
|
|
133
|
+
clauses.append(
|
|
134
|
+
f"attribute_not_exists({n})"
|
|
135
|
+
if value is False
|
|
136
|
+
else f"attribute_exists({n})"
|
|
137
|
+
)
|
|
138
|
+
elif op == "attributeType":
|
|
139
|
+
clauses.append(f"attribute_type({n}, {ctx.value_alias(value)})")
|
|
140
|
+
elif op == "size":
|
|
141
|
+
clauses.append(f"size({n}) = {ctx.value_alias(value)}")
|
|
142
|
+
else:
|
|
143
|
+
raise ValueError(f"Unknown filter operator '{op}' on field '{field}'")
|
|
144
|
+
|
|
145
|
+
return _join_and(clauses)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _compile_node(ctx: _Ctx, node: Any) -> str:
|
|
149
|
+
clauses: List[str] = []
|
|
150
|
+
for key, value in node.items():
|
|
151
|
+
if value is None:
|
|
152
|
+
continue
|
|
153
|
+
if key in _LOGICAL_KEYS:
|
|
154
|
+
if key in ("and", "or"):
|
|
155
|
+
parts = [p for p in (_compile_node(ctx, s) for s in value) if p]
|
|
156
|
+
if not parts:
|
|
157
|
+
continue
|
|
158
|
+
if len(parts) == 1:
|
|
159
|
+
clauses.append(parts[0])
|
|
160
|
+
else:
|
|
161
|
+
sep = " AND " if key == "and" else " OR "
|
|
162
|
+
clauses.append("(" + sep.join(_wrap(p) for p in parts) + ")")
|
|
163
|
+
else: # not
|
|
164
|
+
inner = _compile_node(ctx, value)
|
|
165
|
+
if inner:
|
|
166
|
+
clauses.append(f"NOT {_wrap(inner)}")
|
|
167
|
+
continue
|
|
168
|
+
clause = _compile_field(ctx, key, value)
|
|
169
|
+
if clause:
|
|
170
|
+
clauses.append(clause)
|
|
171
|
+
return _join_and(clauses)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def compile_filter(
|
|
175
|
+
declarative: Mapping[str, Any],
|
|
176
|
+
serializer: TypeSerializer,
|
|
177
|
+
) -> Optional[Dict[str, Any]]:
|
|
178
|
+
"""Compile a declarative filter tree into a client-shape FilterExpression.
|
|
179
|
+
|
|
180
|
+
Returns ``None`` for an empty / no-op filter so callers can skip attaching
|
|
181
|
+
it. Otherwise returns
|
|
182
|
+
``{"FilterExpression", "ExpressionAttributeNames", "ExpressionAttributeValues"}``.
|
|
183
|
+
"""
|
|
184
|
+
if not declarative:
|
|
185
|
+
return None
|
|
186
|
+
ctx = _Ctx(serializer)
|
|
187
|
+
expr = _compile_node(ctx, declarative)
|
|
188
|
+
if not expr:
|
|
189
|
+
return None
|
|
190
|
+
return {
|
|
191
|
+
"FilterExpression": expr,
|
|
192
|
+
"ExpressionAttributeNames": ctx.names,
|
|
193
|
+
"ExpressionAttributeValues": ctx.values,
|
|
194
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Hydration of raw DynamoDB items into result dicts (issue #44).
|
|
2
|
+
|
|
3
|
+
Mirrors the TS hydrator (``src/hydrator/hydrator.ts``):
|
|
4
|
+
|
|
5
|
+
- only fields named in ``select`` (value ``True``) are copied out;
|
|
6
|
+
- internal key attributes (``PK`` / ``SK`` / ``GSI*PK`` / ``GSI*SK``) and the
|
|
7
|
+
entity's PK prefix are never part of the result (they are simply not selected,
|
|
8
|
+
so they are dropped naturally);
|
|
9
|
+
- a ``string`` field whose manifest carries ``format: "datetime"`` is restored
|
|
10
|
+
to a ``datetime``; ``format: "date"`` to a ``datetime`` at midnight UTC.
|
|
11
|
+
|
|
12
|
+
Relation keys in the select (objects, not ``True``) are skipped here; the
|
|
13
|
+
single-operation core has no relations to assemble, so a relation select simply
|
|
14
|
+
contributes nothing to the hydrated root item.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any, Dict, Mapping
|
|
21
|
+
|
|
22
|
+
from .errors import HydrationError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_internal_key(name: str) -> bool:
|
|
26
|
+
if name in ("PK", "SK"):
|
|
27
|
+
return True
|
|
28
|
+
# GSI1PK / GSI1SK / GSI12PK ... — index key attributes.
|
|
29
|
+
return name.startswith("GSI") and (name.endswith("PK") or name.endswith("SK"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def hydrate_item(
|
|
33
|
+
raw: Mapping[str, Any],
|
|
34
|
+
select: Mapping[str, Any],
|
|
35
|
+
entity_meta: Mapping[str, Any],
|
|
36
|
+
) -> Dict[str, Any]:
|
|
37
|
+
"""Hydrate a single deserialized item against a select + entity manifest."""
|
|
38
|
+
fields = entity_meta.get("fields", {})
|
|
39
|
+
result: Dict[str, Any] = {}
|
|
40
|
+
|
|
41
|
+
for field_name, select_value in select.items():
|
|
42
|
+
if select_value is True:
|
|
43
|
+
if _is_internal_key(field_name):
|
|
44
|
+
continue
|
|
45
|
+
if field_name in raw:
|
|
46
|
+
field_meta = fields.get(field_name)
|
|
47
|
+
result[field_name] = _deserialize_value(raw[field_name], field_meta)
|
|
48
|
+
# Relation / nested objects: skipped in the single-op core.
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _deserialize_value(value: Any, field_meta: Mapping[str, Any] | None) -> Any:
|
|
54
|
+
if not field_meta:
|
|
55
|
+
return value
|
|
56
|
+
fmt = field_meta.get("format")
|
|
57
|
+
if fmt == "datetime" and isinstance(value, str):
|
|
58
|
+
return _parse_iso8601(value)
|
|
59
|
+
if fmt == "date" and isinstance(value, str):
|
|
60
|
+
return _parse_iso8601(value + "T00:00:00.000Z")
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_iso8601(value: str) -> datetime:
|
|
65
|
+
"""Parse an ISO 8601 instant (the TS ``toISOString`` form, ``...Z``)."""
|
|
66
|
+
text = value
|
|
67
|
+
if text.endswith("Z"):
|
|
68
|
+
text = text[:-1] + "+00:00"
|
|
69
|
+
try:
|
|
70
|
+
dt = datetime.fromisoformat(text)
|
|
71
|
+
except ValueError as exc:
|
|
72
|
+
raise HydrationError(f"invalid ISO 8601 datetime: {value!r}") from exc
|
|
73
|
+
if dt.tzinfo is None:
|
|
74
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
75
|
+
return dt
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Runtime limits for the GraphDDB Python runtime (issue #44).
|
|
2
|
+
|
|
3
|
+
The single-operation core applies only the limits that are meaningful without
|
|
4
|
+
relation traversal (``max_operations`` and ``max_items``); the remaining fields
|
|
5
|
+
are defined here so the full surface exists for the relation runtime (#45).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class RuntimeLimits:
|
|
15
|
+
"""Execution-time upper bounds, layered on top of the TS-defined limits."""
|
|
16
|
+
|
|
17
|
+
max_operations: int = 20
|
|
18
|
+
max_items: int = 100
|
|
19
|
+
max_depth: int = 1
|
|
20
|
+
max_batch_get_items: int = 100
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Per-key cursor envelope for batched ``range`` contract methods (issue #62,
|
|
2
|
+
CQRS single-service runtime; spec ``docs/cqrs-contract.md``,
|
|
3
|
+
"Pagination under batch + list").
|
|
4
|
+
|
|
5
|
+
A ``range`` contract method paginates **per key**: each key owns its own
|
|
6
|
+
connection and therefore its own pagination position. The proposal requires that
|
|
7
|
+
"the cursor envelope must carry the key it belongs to" so a caller can never
|
|
8
|
+
accidentally resume one key's pagination against another key.
|
|
9
|
+
|
|
10
|
+
This module wraps the **inner** page cursor (the base64url-encoded DynamoDB
|
|
11
|
+
``LastEvaluatedKey`` produced by :func:`encode_cursor`) together with a **stable
|
|
12
|
+
identity of the owning key** into a single opaque envelope, itself base64url
|
|
13
|
+
JSON. It is a byte-for-byte port of the TypeScript ``src/runtime/per-key-cursor.ts``
|
|
14
|
+
so TS and Python mint and accept the *same* cursor strings (the parity
|
|
15
|
+
foundation conformance #65 will lock):
|
|
16
|
+
|
|
17
|
+
- :func:`serialize_contract_key` — canonical, field-sorted JSON identity of a
|
|
18
|
+
key (matching the TS ``serializeContractKey``).
|
|
19
|
+
- :func:`encode_per_key_cursor` — ``{key, inner}`` envelope → base64url string,
|
|
20
|
+
or ``None`` when there is no further page.
|
|
21
|
+
- :func:`decode_per_key_cursor` — decode + **verify** the envelope belongs to the
|
|
22
|
+
key being read.
|
|
23
|
+
|
|
24
|
+
Even the single-key range form is wrapped, keeping the cursor shape uniform
|
|
25
|
+
(a single contract's range method is ``inputArity: 'single'`` — see the runtime's
|
|
26
|
+
``execute_query_method`` — so the array fan-out is #63 territory, but the
|
|
27
|
+
envelope shape does not branch on arity).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
from typing import Any, Mapping, Optional
|
|
34
|
+
|
|
35
|
+
from .cursor import decode_cursor, encode_cursor
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def serialize_contract_key(key: Mapping[str, Any]) -> str:
|
|
39
|
+
"""Canonical, cross-runtime-stable string identity of a contract key.
|
|
40
|
+
|
|
41
|
+
Object fields are sorted by name so ``{a, b}`` and ``{b, a}`` serialize
|
|
42
|
+
identically; values are emitted via ``json.dumps`` with compact separators.
|
|
43
|
+
Matches the TS ``serializeContractKey`` (``JSON.stringify`` over a
|
|
44
|
+
field-sorted object), e.g. ``{"categoryId":"tech"}``.
|
|
45
|
+
"""
|
|
46
|
+
ordered = {field: key[field] for field in sorted(key.keys())}
|
|
47
|
+
return json.dumps(ordered, separators=(",", ":"), ensure_ascii=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def encode_per_key_cursor(
|
|
51
|
+
key: Mapping[str, Any], inner: Optional[str]
|
|
52
|
+
) -> Optional[str]:
|
|
53
|
+
"""Build a per-key cursor envelope from the owning key and an inner page
|
|
54
|
+
cursor, encoded as a single opaque base64url string.
|
|
55
|
+
|
|
56
|
+
Returns ``None`` when ``inner`` is ``None`` (the key has no further pages — a
|
|
57
|
+
terminal connection has a ``None`` cursor, never an envelope wrapping
|
|
58
|
+
nothing).
|
|
59
|
+
"""
|
|
60
|
+
if inner is None:
|
|
61
|
+
return None
|
|
62
|
+
envelope = {"key": serialize_contract_key(key), "inner": inner}
|
|
63
|
+
return encode_cursor(envelope)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def decode_per_key_cursor(cursor: str, expected_key: Mapping[str, Any]) -> str:
|
|
67
|
+
"""Decode a per-key cursor envelope and **verify** it belongs to
|
|
68
|
+
``expected_key``.
|
|
69
|
+
|
|
70
|
+
A cursor minted for one key fed back for another is a caller error (it would
|
|
71
|
+
silently resume the wrong key's pagination), so it is rejected.
|
|
72
|
+
|
|
73
|
+
Returns the inner page cursor to hand to the underlying ``Query``.
|
|
74
|
+
|
|
75
|
+
:raises ValueError: if the envelope is malformed, or its key identity does
|
|
76
|
+
not match ``expected_key``.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
envelope = decode_cursor(cursor)
|
|
80
|
+
except Exception as exc: # noqa: BLE001 - any decode failure is a bad cursor
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"Invalid per-key cursor: the value passed as `after` is not a cursor "
|
|
83
|
+
"minted by this runtime (it failed to decode)."
|
|
84
|
+
) from exc
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
not isinstance(envelope, dict)
|
|
88
|
+
or not isinstance(envelope.get("key"), str)
|
|
89
|
+
or not isinstance(envelope.get("inner"), str)
|
|
90
|
+
):
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"Invalid per-key cursor: the decoded envelope is missing its key / "
|
|
93
|
+
"inner fields. A range cursor must be a per-key envelope minted by "
|
|
94
|
+
"this runtime."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
expected = serialize_contract_key(expected_key)
|
|
98
|
+
if envelope["key"] != expected:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"Per-key cursor mismatch: the supplied `after` cursor belongs to key "
|
|
101
|
+
f"{envelope['key']}, but the method is being called for key {expected}. "
|
|
102
|
+
f"A range cursor may only resume pagination of the same key it was "
|
|
103
|
+
f"issued for."
|
|
104
|
+
)
|
|
105
|
+
return envelope["inner"]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Multi-operation relation traversal / result assembly (issue #45).
|
|
2
|
+
|
|
3
|
+
The single-operation core (#44) executes one ``OperationSpec`` and returns its
|
|
4
|
+
result. A relation query is expressed (by the #42 static planner) as **several**
|
|
5
|
+
operations whose ``resultPath`` / ``{result.<sourceField>}`` templates wire them
|
|
6
|
+
into a tree, mirroring the TypeScript runtime semantics:
|
|
7
|
+
|
|
8
|
+
- ``hasMany`` → a per-parent ``Query`` (with an optional ``begins_with`` range
|
|
9
|
+
and server-side ``FilterExpression``) producing a ``{items, cursor}``
|
|
10
|
+
connection (``src/relation/traversal.ts`` ``resolveRelations`` hasMany branch);
|
|
11
|
+
- ``belongsTo`` / ``hasOne`` → a single ``BatchGetItem`` over **all** parents'
|
|
12
|
+
child keys, with **dedup**, 100-key chunking, and ``UnprocessedKeys``
|
|
13
|
+
exponential-backoff retry, matched back to parents by key (no per-parent
|
|
14
|
+
``GetItem`` — N+1 avoided), mirroring ``planBatchGetForQueryKeys`` +
|
|
15
|
+
``executeBatchGet`` + ``batchGetChunkWithRetry``.
|
|
16
|
+
|
|
17
|
+
This module owns the orchestration; the executing runtime injects callables for
|
|
18
|
+
the actual boto3 work so the relation logic stays testable without a client.
|
|
19
|
+
|
|
20
|
+
## resultPath grammar
|
|
21
|
+
|
|
22
|
+
A ``resultPath`` is ``$`` (root) or ``$`` followed by ``.``-separated tokens.
|
|
23
|
+
A trailing ``items`` token means the write target is a hasMany **connection**
|
|
24
|
+
(``{items, cursor}``); the token immediately before ``items`` is the property
|
|
25
|
+
name. Otherwise the final token is the property name for a single-value
|
|
26
|
+
(belongsTo / hasOne) relation. ``items`` tokens in the interior mean "iterate
|
|
27
|
+
into the elements of that connection".
|
|
28
|
+
|
|
29
|
+
Examples (root op already placed at ``$``):
|
|
30
|
+
|
|
31
|
+
- ``$.members.items`` → root.members = connection
|
|
32
|
+
- ``$.groups.items.group`` → for each g in
|
|
33
|
+
root.groups.items: g.group = item|None
|
|
34
|
+
- ``$.groups.items.group.permissions.items`` → for each non-null
|
|
35
|
+
root.groups.items[*].group: .permissions = connection
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
41
|
+
|
|
42
|
+
ITEMS = "items"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_result_path(path: str) -> Tuple[List[str], str, bool]:
|
|
46
|
+
"""Split a ``resultPath`` into (parent_tokens, write_key, is_connection).
|
|
47
|
+
|
|
48
|
+
``parent_tokens`` is the token sequence to navigate from the root to the set
|
|
49
|
+
of parent nodes the operation writes onto (``items`` tokens iterate into
|
|
50
|
+
connections). ``write_key`` is the property each parent gets; ``is_connection``
|
|
51
|
+
is True when the write target is a hasMany ``{items, cursor}`` connection.
|
|
52
|
+
"""
|
|
53
|
+
if path == "$" or path == "":
|
|
54
|
+
raise ValueError("root operation has no relation path")
|
|
55
|
+
if not path.startswith("$."):
|
|
56
|
+
raise ValueError(f"unsupported resultPath {path!r}")
|
|
57
|
+
tokens = path[2:].split(".")
|
|
58
|
+
if tokens[-1] == ITEMS:
|
|
59
|
+
# ...<prop>.items → connection written at <prop>.
|
|
60
|
+
if len(tokens) < 2:
|
|
61
|
+
raise ValueError(f"malformed resultPath {path!r}")
|
|
62
|
+
write_key = tokens[-2]
|
|
63
|
+
parent_tokens = tokens[:-2]
|
|
64
|
+
return parent_tokens, write_key, True
|
|
65
|
+
# ...<prop> → single-value relation written at <prop>.
|
|
66
|
+
write_key = tokens[-1]
|
|
67
|
+
parent_tokens = tokens[:-1]
|
|
68
|
+
return parent_tokens, write_key, False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def collect_parents(root: Any, parent_tokens: List[str]) -> List[Dict[str, Any]]:
|
|
72
|
+
"""Navigate ``parent_tokens`` from ``root`` to the list of parent dicts.
|
|
73
|
+
|
|
74
|
+
``items`` tokens expand into each element of the connection they follow.
|
|
75
|
+
``None`` nodes (an unresolved belongsTo) are skipped, so a downstream
|
|
76
|
+
relation simply has no parent to attach to.
|
|
77
|
+
"""
|
|
78
|
+
nodes: List[Any] = [root]
|
|
79
|
+
i = 0
|
|
80
|
+
while i < len(parent_tokens):
|
|
81
|
+
token = parent_tokens[i]
|
|
82
|
+
nxt: List[Any] = []
|
|
83
|
+
if token == ITEMS:
|
|
84
|
+
for node in nodes:
|
|
85
|
+
if isinstance(node, dict):
|
|
86
|
+
nxt.extend(node.get(ITEMS, []) or [])
|
|
87
|
+
else:
|
|
88
|
+
for node in nodes:
|
|
89
|
+
if isinstance(node, dict):
|
|
90
|
+
child = node.get(token)
|
|
91
|
+
if child is not None:
|
|
92
|
+
nxt.append(child)
|
|
93
|
+
nodes = nxt
|
|
94
|
+
i += 1
|
|
95
|
+
return [n for n in nodes if isinstance(n, dict)]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class RelationAssembler:
|
|
99
|
+
"""Assembles a multi-operation result tree from per-operation executors.
|
|
100
|
+
|
|
101
|
+
The runtime supplies three callables (so this stays client-free):
|
|
102
|
+
|
|
103
|
+
- ``run_query(op, source_values) -> {"items": [...], "cursor": str|None}``
|
|
104
|
+
executes a single per-parent hasMany Query for one resolved source value.
|
|
105
|
+
- ``run_batch_get(op, source_values) -> {serialized_key: item}`` executes a
|
|
106
|
+
deduped, chunked, retrying BatchGetItem over **all** parents' source values
|
|
107
|
+
and returns a map from a serialized child key back to the resolved item.
|
|
108
|
+
- ``key_for(op, source_value) -> serialized_key`` produces the same
|
|
109
|
+
serialized key ``run_batch_get`` uses, for matching items back to parents.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
run_query: Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]],
|
|
116
|
+
run_batch_get: Callable[
|
|
117
|
+
[Dict[str, Any], List[Dict[str, Any]]], Dict[str, Any]
|
|
118
|
+
],
|
|
119
|
+
key_for: Callable[[Dict[str, Any], Dict[str, Any]], str],
|
|
120
|
+
) -> None:
|
|
121
|
+
self._run_query = run_query
|
|
122
|
+
self._run_batch_get = run_batch_get
|
|
123
|
+
self._key_for = key_for
|
|
124
|
+
|
|
125
|
+
def apply(self, root: Any, op: Dict[str, Any]) -> None:
|
|
126
|
+
"""Resolve one relation operation and merge its results into ``root``."""
|
|
127
|
+
parent_tokens, write_key, is_connection = parse_result_path(
|
|
128
|
+
op["resultPath"]
|
|
129
|
+
)
|
|
130
|
+
parents = collect_parents(root, parent_tokens)
|
|
131
|
+
if not parents:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
if op["type"] == "BatchGetItem":
|
|
135
|
+
self._apply_batch_get(parents, op, write_key)
|
|
136
|
+
else: # Query (hasMany)
|
|
137
|
+
self._apply_query(parents, op, write_key)
|
|
138
|
+
|
|
139
|
+
# ── belongsTo / hasOne: a single BatchGetItem across all parents ────────────
|
|
140
|
+
|
|
141
|
+
def _apply_batch_get(
|
|
142
|
+
self,
|
|
143
|
+
parents: List[Dict[str, Any]],
|
|
144
|
+
op: Dict[str, Any],
|
|
145
|
+
write_key: str,
|
|
146
|
+
) -> None:
|
|
147
|
+
source_field = op["sourceField"]
|
|
148
|
+
# Gather every parent's source value; parents missing it resolve to None.
|
|
149
|
+
source_values: List[Dict[str, Any]] = []
|
|
150
|
+
seen_sources: set = set()
|
|
151
|
+
for parent in parents:
|
|
152
|
+
sv = self._source_values(parent, source_field)
|
|
153
|
+
if sv is None:
|
|
154
|
+
continue
|
|
155
|
+
marker = self._key_for(op, sv)
|
|
156
|
+
if marker in seen_sources:
|
|
157
|
+
continue
|
|
158
|
+
seen_sources.add(marker)
|
|
159
|
+
source_values.append(sv)
|
|
160
|
+
|
|
161
|
+
key_to_item = self._run_batch_get(op, source_values)
|
|
162
|
+
|
|
163
|
+
for parent in parents:
|
|
164
|
+
sv = self._source_values(parent, source_field)
|
|
165
|
+
if sv is None:
|
|
166
|
+
parent[write_key] = None
|
|
167
|
+
continue
|
|
168
|
+
parent[write_key] = key_to_item.get(self._key_for(op, sv))
|
|
169
|
+
|
|
170
|
+
# ── hasMany: a per-parent Query ─────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def _apply_query(
|
|
173
|
+
self,
|
|
174
|
+
parents: List[Dict[str, Any]],
|
|
175
|
+
op: Dict[str, Any],
|
|
176
|
+
write_key: str,
|
|
177
|
+
) -> None:
|
|
178
|
+
source_field = op["sourceField"]
|
|
179
|
+
for parent in parents:
|
|
180
|
+
sv = self._source_values(parent, source_field)
|
|
181
|
+
if sv is None:
|
|
182
|
+
parent[write_key] = {"items": [], "cursor": None}
|
|
183
|
+
continue
|
|
184
|
+
parent[write_key] = self._run_query(op, sv)
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _source_values(parent: Dict[str, Any], source_field: str) -> Optional[
|
|
188
|
+
Dict[str, Any]
|
|
189
|
+
]:
|
|
190
|
+
"""Return the ``{source_field: value}`` binding for a parent, or None.
|
|
191
|
+
|
|
192
|
+
``None`` (value absent / null) means the relation cannot be resolved for
|
|
193
|
+
this parent — it gets ``None`` (belongsTo) or an empty connection
|
|
194
|
+
(hasMany), matching the TS ``hasCompleteQueryKey`` guard.
|
|
195
|
+
"""
|
|
196
|
+
value = parent.get(source_field)
|
|
197
|
+
if value is None:
|
|
198
|
+
return None
|
|
199
|
+
return {source_field: value}
|