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.
@@ -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}