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,1674 @@
1
+ """GraphDDBRuntime — the DynamoDB executor (issues #44 single-op, #45 relations).
2
+
3
+ Loads the ``manifest.json`` / ``operations.json`` produced by ``graphddb
4
+ generate python`` and executes the validated access patterns via boto3.
5
+
6
+ The single-operation core (#44) executes one ``OperationSpec``. Relation queries
7
+ (#45) are expressed as **multiple** operations wired by ``resultPath`` /
8
+ ``{result.<sourceField>}`` templates; this runtime executes them in order,
9
+ resolving prior-result-dependent keys, assembling the result tree, and
10
+ collapsing ``belongsTo`` / ``hasOne`` resolution into a single ``BatchGetItem``
11
+ (dedup + chunk + ``UnprocessedKeys`` retry) so N+1 ``GetItem`` does not occur.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from typing import Any, Dict, List, Mapping, Optional
19
+
20
+ from boto3.dynamodb.types import TypeDeserializer, TypeSerializer
21
+
22
+ from .batch import BatchGetExecutor, BatchWriteExecutor, serialize_key
23
+ from .concurrency import RELATION_TRAVERSAL_CONCURRENCY, map_with_concurrency
24
+ from .cursor import decode_cursor, encode_cursor
25
+ from .errors import (
26
+ CommandNotFoundError,
27
+ ContractArityError,
28
+ ContractNotFoundError,
29
+ GraphDDBError,
30
+ LimitExceededError,
31
+ OperationExecutionError,
32
+ QueryNotFoundError,
33
+ TransactionNotFoundError,
34
+ )
35
+ from .filters import compile_filter
36
+ from .hydration import hydrate_item
37
+ from .limits import RuntimeLimits
38
+ from .per_key_cursor import (
39
+ decode_per_key_cursor,
40
+ encode_per_key_cursor,
41
+ serialize_contract_key,
42
+ )
43
+ from .relations import RelationAssembler
44
+ from .templates import (
45
+ resolve_template,
46
+ validate_params,
47
+ )
48
+ from .transactions import MAX_TRANSACT_ITEMS, TransactionExpander
49
+
50
+ try: # pragma: no cover - exercised indirectly
51
+ from botocore.exceptions import BotoCoreError, ClientError
52
+
53
+ _BOTO_ERRORS: tuple = (ClientError, BotoCoreError)
54
+ except Exception: # pragma: no cover
55
+ _BOTO_ERRORS = ()
56
+
57
+ _PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
58
+
59
+
60
+ def _normalize_template(template: Optional[str]) -> Optional[str]:
61
+ """Collapse every ``{placeholder}`` to a wildcard for structural matching."""
62
+ if template is None:
63
+ return None
64
+ return _PLACEHOLDER_RE.sub("\x00", template)
65
+
66
+
67
+ def _present(records: Any) -> List[Dict[str, Any]]:
68
+ """The present (non-``None``, dict) parent records from a result collection.
69
+
70
+ A keyed-batch map carries explicit ``None`` for misses, and a point read may
71
+ yield ``None``; only present records can carry a composed child (#63).
72
+ """
73
+ return [r for r in records if isinstance(r, dict)]
74
+
75
+
76
+ def _read_from_path(record: Mapping[str, Any], path: str) -> Any:
77
+ """Read a declarative ``from`` path (``$.field`` / ``$.a.b``) off a parent.
78
+
79
+ ``$`` is the record root; each dotted segment descends one field. Returns
80
+ ``None`` when any segment is missing (treated as an absent child key →
81
+ explicit ``None`` child). Mirrors the TS ``readFromPath``.
82
+ """
83
+ if path == "$":
84
+ return record
85
+ current: Any = record
86
+ for segment in path[2:].split("."):
87
+ if not isinstance(current, Mapping):
88
+ return None
89
+ current = current.get(segment)
90
+ return current
91
+
92
+
93
+ class GraphDDBRuntime:
94
+ """Executes generated single- and multi-operation queries / commands."""
95
+
96
+ def __init__(
97
+ self,
98
+ dynamodb_client: Any,
99
+ manifest_path: str,
100
+ operations_path: str,
101
+ table_mapping: Optional[Mapping[str, str]] = None,
102
+ limits: Optional[RuntimeLimits] = None,
103
+ ) -> None:
104
+ self._client = dynamodb_client
105
+ self._table_mapping = dict(table_mapping or {})
106
+ self._limits = limits or RuntimeLimits()
107
+ self._serializer = TypeSerializer()
108
+ self._deserializer = TypeDeserializer()
109
+
110
+ with open(manifest_path, "r", encoding="utf-8") as fh:
111
+ self._manifest: Dict[str, Any] = json.load(fh)
112
+ with open(operations_path, "r", encoding="utf-8") as fh:
113
+ self._operations: Dict[str, Any] = json.load(fh)
114
+
115
+ self._queries: Dict[str, Any] = self._operations.get("queries", {})
116
+ self._commands: Dict[str, Any] = self._operations.get("commands", {})
117
+ self._transactions: Dict[str, Any] = self._operations.get("transactions", {})
118
+ # CQRS Contract layer (#59 SSoT → #62 runtime): contract name →
119
+ # ContractSpec, layered on top of `queries`. Absent in pre-#59 documents.
120
+ self._contracts: Dict[str, Any] = self._operations.get("contracts", {})
121
+ self._entities: Dict[str, Any] = self._manifest.get("entities", {})
122
+
123
+ # ── public API ────────────────────────────────────────────────────────────
124
+
125
+ def execute_query(
126
+ self,
127
+ query_id: str,
128
+ params: Mapping[str, Any],
129
+ options: Optional[Mapping[str, Any]] = None,
130
+ ) -> Optional[dict]:
131
+ spec = self._queries.get(query_id)
132
+ if spec is None:
133
+ raise QueryNotFoundError(f"unknown query '{query_id}'")
134
+
135
+ validate_params(params, spec.get("params", {}), operation_id=query_id)
136
+
137
+ operations: List[Dict[str, Any]] = list(spec.get("operations", []))
138
+ self._enforce_operation_limits(query_id, operations)
139
+
140
+ root_op = operations[0]
141
+ cardinality = spec.get("cardinality")
142
+
143
+ root = self._run_root(query_id, root_op, params, options or {}, cardinality)
144
+ if root is None:
145
+ return None
146
+
147
+ assembler = RelationAssembler(
148
+ run_query=lambda op, sv: self._run_relation_query(query_id, op, sv),
149
+ run_batch_get=lambda op, svs: self._run_relation_batch_get(
150
+ query_id, op, svs
151
+ ),
152
+ key_for=self._relation_key_marker,
153
+ )
154
+ self._assemble_relations(spec, operations, root, assembler)
155
+ return root
156
+
157
+ def _assemble_relations(
158
+ self,
159
+ spec: Mapping[str, Any],
160
+ operations: List[Dict[str, Any]],
161
+ root: Any,
162
+ assembler: RelationAssembler,
163
+ ) -> None:
164
+ """Apply the relation operations onto ``root``, HONORING the execution plan.
165
+
166
+ The SSoT execution plan (issue #70a) partitions ``operations`` into ordered
167
+ *stages*: the operations within a stage are mutually independent (none reads
168
+ a ``{result.*}`` value another produces — e.g. sibling relations sharing a
169
+ parent path), so this runtime issues them **concurrently** under the
170
+ declared in-flight bound (``executionPlan.concurrency``), while the stages
171
+ run in order because a later stage depends on an earlier one's results
172
+ (issue #70b). Each op in a stage writes a **disjoint** slot of the result
173
+ tree (its own parent records' property), so completion order does not affect
174
+ the merged output.
175
+
176
+ Backward compatibility: a spec **without** an ``executionPlan`` (a pre-#70a
177
+ document, or a single-operation read) falls back to one-operation-per-stage
178
+ **sequential** assembly — byte-for-byte the pre-#70b behavior. The ``concurrency``
179
+ helper also runs a single-member stage inline (no thread), so a plan whose
180
+ stages each hold one op is identical to sequential too.
181
+ """
182
+ stages = self._relation_stages(spec, operations)
183
+ for stage in stages:
184
+ stage_ops = [operations[i] for i in stage]
185
+ # Independent ops of one stage → bounded-concurrent apply. The helper
186
+ # preserves order and runs a single-op stage inline (sequential).
187
+ map_with_concurrency(
188
+ stage_ops,
189
+ self._plan_concurrency(spec),
190
+ lambda op, _i: assembler.apply(root, op),
191
+ )
192
+
193
+ @staticmethod
194
+ def _relation_stages(
195
+ spec: Mapping[str, Any], operations: List[Dict[str, Any]]
196
+ ) -> List[List[int]]:
197
+ """The ordered relation stages (indices into ``operations``), excluding the
198
+ root (index 0).
199
+
200
+ Reads ``executionPlan.groups`` (issue #70a) when present, dropping the root
201
+ index from each group; an empty group is skipped. When the plan is **absent**
202
+ (backward compatibility), falls back to one stage per relation op in emission
203
+ order — the pre-#70 sequential ordering, where each op is its own stage.
204
+ """
205
+ plan = spec.get("executionPlan")
206
+ if plan and isinstance(plan.get("groups"), list):
207
+ stages: List[List[int]] = []
208
+ for group in plan["groups"]:
209
+ relation_indices = [i for i in group if i != 0]
210
+ if relation_indices:
211
+ stages.append(relation_indices)
212
+ return stages
213
+ # No plan → each relation op is its own (sequential) stage.
214
+ return [[i] for i in range(1, len(operations))]
215
+
216
+ @staticmethod
217
+ def _plan_concurrency(spec: Mapping[str, Any]) -> int:
218
+ """The declared in-flight bound from the execution plan, or the default.
219
+
220
+ A spec without an ``executionPlan`` (or one omitting ``concurrency``) uses
221
+ the shared default :data:`RELATION_TRAVERSAL_CONCURRENCY` (16) — but a
222
+ plan-less spec only ever produces single-op stages, which run inline, so the
223
+ bound is moot in that case (backward compatibility).
224
+ """
225
+ plan = spec.get("executionPlan") or {}
226
+ concurrency = plan.get("concurrency")
227
+ if isinstance(concurrency, int) and concurrency > 0:
228
+ return concurrency
229
+ return RELATION_TRAVERSAL_CONCURRENCY
230
+
231
+ # ── contract method execution (issue #62, single-service Runtime) ───────────
232
+
233
+ def execute_query_method(
234
+ self,
235
+ contract_name: str,
236
+ method_name: str,
237
+ key_or_keys: Any,
238
+ params: Optional[Mapping[str, Any]] = None,
239
+ ) -> Any:
240
+ """Execute one CQRS **query**-contract method for a single key OR an array
241
+ of keys (issue #62; proposal "Query Method — Common Interface" +
242
+ "Cardinality matrix").
243
+
244
+ The result shape follows the proposal's cardinality matrix, decided by the
245
+ method's serialized ``resolution`` and the input cardinality (the runtime
246
+ **honors** the decided facts — it never re-derives them):
247
+
248
+ - ``point`` + single key → ``Item | None`` (key1 : result1)
249
+ - ``point`` + ``list[key]`` → ``dict[keyId, Item | None]`` (keyN : resultN)
250
+ - ``range`` + single key → ``{"items", "cursor"}`` connection (key1 : resultM)
251
+
252
+ ``range`` + ``list[key]`` (``keyN : resultN×M``) is **unreachable for a
253
+ single contract**: a ``range`` method's ``inputArity`` is ``'single'`` (the
254
+ N+1 rule), so an array fed into one is rejected with
255
+ :class:`ContractArityError` rather than fanned out. (A keyed map of
256
+ connections only arises under cross-contract composition, #63 — out of
257
+ scope here.)
258
+
259
+ The keyed map (``keyN : resultN``) is keyed by the canonical
260
+ :func:`serialize_contract_key` identity; every input key is present, a
261
+ missing item carried as an explicit ``None`` (BatchGet neither preserves
262
+ order nor returns absent keys). The ``range`` connection's ``cursor`` is a
263
+ **per-key envelope** that carries the key it paginates.
264
+
265
+ :param contract_name: A contract in the ``contracts`` SSoT map.
266
+ :param method_name: A query method on that contract.
267
+ :param key_or_keys: A single key mapping, or a list of key mappings.
268
+ :param params: Retrieval options (``consistentRead`` / ``limit`` /
269
+ ``after`` / ``order``). The ``after`` cursor is a per-key envelope.
270
+ """
271
+ method = self._resolve_contract_query_method(contract_name, method_name)
272
+ resolution = method["resolution"]
273
+ input_arity = method.get("inputArity")
274
+ op_spec = self._contract_query_op(contract_name, method_name, method)
275
+ compose = list(method.get("compose") or [])
276
+ composition_plan = method.get("compositionPlan")
277
+ opts = dict(params or {})
278
+
279
+ is_array = isinstance(key_or_keys, (list, tuple))
280
+ if is_array:
281
+ # Only a method whose ``inputArity`` is ``'either'`` coalesces a key
282
+ # array into one batched call. A ``'single'`` method must reject an
283
+ # array — that covers BOTH a ``range`` method (one partition Query per
284
+ # key) AND a unique-GSI ``point`` method (one GSI Query per key;
285
+ # BatchGetItem cannot read a GSI, issue #71). Either is an N+1 fan-out,
286
+ # already rejected at build time (#60/#71 checker); this is the
287
+ # execution backstop, identical to the TS runtime.
288
+ # `inputArity` is always serialized for a query method; fall back to
289
+ # `resolution == 'range'` so a spec that somehow omits it still rejects
290
+ # a range array (defense-in-depth — `resolution` is structurally always
291
+ # present, mirroring the pre-#71 guard).
292
+ if input_arity == "single" or (input_arity is None and resolution == "range"):
293
+ kind = "range" if resolution == "range" else "unique-GSI point"
294
+ per_key = "partition" if resolution == "range" else "GSI"
295
+ raise ContractArityError(
296
+ f"{contract_name}.{method_name}: {kind} method called with an "
297
+ f"array of keys, but its inputArity is 'single'. A {kind} "
298
+ f"resolution is one {per_key} Query per key, so an array would "
299
+ f"be an N+1 fan-out — forbidden by the contract's N+1 rule. "
300
+ f"Loop in application code for N independent reads."
301
+ )
302
+ keyed = self._execute_point_batch(
303
+ contract_name, method_name, op_spec, list(key_or_keys)
304
+ )
305
+ # Compose every present parent record in the keyed map (#63): the
306
+ # child is resolved ONCE, batched, across all parent records (no N+1).
307
+ self._resolve_compositions(
308
+ contract_name,
309
+ method_name,
310
+ compose,
311
+ _present(keyed.values()),
312
+ composition_plan,
313
+ )
314
+ return keyed
315
+
316
+ key = key_or_keys
317
+ if resolution == "point":
318
+ item = self._execute_point_single(
319
+ contract_name, method_name, op_spec, key, opts
320
+ )
321
+ if item is not None:
322
+ self._resolve_compositions(
323
+ contract_name, method_name, compose, [item], composition_plan
324
+ )
325
+ return item
326
+ connection = self._execute_range_single(
327
+ contract_name, method_name, op_spec, key, opts
328
+ )
329
+ # A range parent yields N records (the connection items); compose them
330
+ # with a single batched child read across all items.
331
+ self._resolve_compositions(
332
+ contract_name,
333
+ method_name,
334
+ compose,
335
+ _present(connection["items"]),
336
+ composition_plan,
337
+ )
338
+ return connection
339
+
340
+ def _resolve_contract_query_method(
341
+ self, contract_name: str, method_name: str
342
+ ) -> Mapping[str, Any]:
343
+ contract = self._contracts.get(contract_name)
344
+ if contract is None:
345
+ available = ", ".join(sorted(self._contracts.keys())) or "(none)"
346
+ raise ContractNotFoundError(
347
+ f"unknown contract '{contract_name}'. Available: {available}"
348
+ )
349
+ if contract.get("kind") != "query":
350
+ raise ContractNotFoundError(
351
+ f"contract '{contract_name}' is a '{contract.get('kind')}' contract; "
352
+ f"execute_query_method only runs query contracts (commands are #64)."
353
+ )
354
+ methods = contract.get("methods", {})
355
+ method = methods.get(method_name)
356
+ if method is None:
357
+ available = ", ".join(sorted(methods.keys())) or "(none)"
358
+ raise ContractNotFoundError(
359
+ f"contract '{contract_name}' has no query method '{method_name}'. "
360
+ f"Available: {available}"
361
+ )
362
+ return method
363
+
364
+ def _contract_query_op(
365
+ self, contract_name: str, method_name: str, method: Mapping[str, Any]
366
+ ) -> Mapping[str, Any]:
367
+ """The single referenced read op spec for a contract query method.
368
+
369
+ A contract method resolves to exactly one declarative read op (#58). Any
370
+ External Query compositions (#63) it declares are carried separately on
371
+ the method spec (``compose``) and resolved by :meth:`_resolve_compositions`
372
+ after the parent step — they do **not** add operations to this op spec.
373
+ """
374
+ op_name = method["operation"]
375
+ spec = self._queries.get(op_name)
376
+ if spec is None:
377
+ raise ContractNotFoundError(
378
+ f"{contract_name}.{method_name}: referenced operation '{op_name}' is "
379
+ f"not present in `queries`."
380
+ )
381
+ operations = spec.get("operations", [])
382
+ if len(operations) != 1:
383
+ raise GraphDDBError(
384
+ f"{contract_name}.{method_name}: a single-contract query method must "
385
+ f"resolve to exactly one read operation, found {len(operations)}."
386
+ )
387
+ return spec
388
+
389
+ def _execute_point_single(
390
+ self,
391
+ contract_name: str,
392
+ method_name: str,
393
+ spec: Mapping[str, Any],
394
+ key: Mapping[str, Any],
395
+ opts: Mapping[str, Any],
396
+ ) -> Optional[dict]:
397
+ """point + single key → Item | None (GetItem / unique Query).
398
+
399
+ Delegates to the proven single-op executor via the referenced op spec.
400
+ Pagination / ordering retrieval options do not apply to a point read (at
401
+ most one item per key), so none are consumed here — mirroring the TS
402
+ runtime's ``executePointSingle``. The ``consistentRead`` retrieval option,
403
+ however, **does** apply to a point read on the **base table** (issue #65):
404
+ it is forwarded so the runtime issues a strongly-consistent ``GetItem`` /
405
+ base-table ``Query`` with the **identical** request the TS runtime issues
406
+ (``executeQuery`` → ``executeGetItem`` with ``ConsistentRead``). This closes
407
+ the parity gap #62's audit surfaced (Python previously ignored it).
408
+
409
+ Boundary (NOT full parity on a GSI): DynamoDB forbids a consistent read on
410
+ a secondary index. The TS planner (``src/planner/planner.ts``) *rejects*
411
+ ``consistentRead: true`` on a GSI-keyed read with a hard error; this runtime
412
+ instead silently drops the flag on a GSI-keyed Query (the ``not
413
+ op.get("indexName")`` guard in :meth:`_run_query`). The two therefore differ
414
+ on the *invalid* GSI+consistentRead combination (TS throws, Python no-ops) —
415
+ a pre-existing #71 GSI-surface divergence, out of scope for #65. Every
416
+ reachable point contract in the SSoT is a base-table read, where the two are
417
+ request-identical; a GSI-keyed consistent point read is not expressible
418
+ through these contracts.
419
+ """
420
+ op = spec["operations"][0]
421
+ validate_params(key, spec.get("params", {}), operation_id=f"{contract_name}.{method_name}")
422
+ select = self._select_from_projection(op)
423
+ entity_meta = self._resolve_entity(op)
424
+ consistent_read = bool(opts.get("consistentRead"))
425
+ op_type = op["type"]
426
+ if op_type == "GetItem":
427
+ return self._run_get_item(
428
+ f"{contract_name}.{method_name}",
429
+ op,
430
+ key,
431
+ select,
432
+ entity_meta,
433
+ consistent_read=consistent_read,
434
+ )
435
+ if op_type == "Query":
436
+ # A unique-key Query (point) — take the first matched item. Forward
437
+ # ``consistentRead`` via the run options (honored only for a base-table
438
+ # query, matching the TS planner).
439
+ connection = self._run_query(
440
+ f"{contract_name}.{method_name}",
441
+ op,
442
+ key,
443
+ select,
444
+ entity_meta,
445
+ {"consistentRead": consistent_read},
446
+ )
447
+ items = connection["items"]
448
+ return items[0] if items else None
449
+ raise GraphDDBError(
450
+ f"{contract_name}.{method_name}: unsupported point read operation "
451
+ f"'{op_type}'"
452
+ )
453
+
454
+ def _execute_range_single(
455
+ self,
456
+ contract_name: str,
457
+ method_name: str,
458
+ spec: Mapping[str, Any],
459
+ key: Mapping[str, Any],
460
+ opts: Mapping[str, Any],
461
+ ) -> Dict[str, Any]:
462
+ """range + single key → connection, with a per-key cursor envelope."""
463
+ op = spec["operations"][0]
464
+ validate_params(key, spec.get("params", {}), operation_id=f"{contract_name}.{method_name}")
465
+ select = self._select_from_projection(op)
466
+ entity_meta = self._resolve_entity(op)
467
+
468
+ # Decode + verify a supplied `after` envelope against this key, then hand
469
+ # the inner page cursor to the underlying Query (its `options.cursor`).
470
+ run_options: Dict[str, Any] = {}
471
+ if opts.get("after") is not None:
472
+ run_options["cursor"] = decode_per_key_cursor(opts["after"], key)
473
+
474
+ # Per-call overrides (limit / order) layer onto the static op spec.
475
+ op = dict(op)
476
+ if opts.get("limit") is not None:
477
+ op["limit"] = opts["limit"]
478
+
479
+ connection = self._run_query(
480
+ f"{contract_name}.{method_name}", op, key, select, entity_meta, run_options
481
+ )
482
+ return {
483
+ "items": connection["items"],
484
+ "cursor": encode_per_key_cursor(key, connection["cursor"]),
485
+ }
486
+
487
+ def _execute_point_batch(
488
+ self,
489
+ contract_name: str,
490
+ method_name: str,
491
+ spec: Mapping[str, Any],
492
+ keys: List[Mapping[str, Any]],
493
+ ) -> Dict[str, Optional[dict]]:
494
+ """point + array → keyed dict[keyId, Item | None] via chunked BatchGetItem.
495
+
496
+ Reuses :class:`BatchGetExecutor` (chunk ≤100, dedup, UnprocessedKeys
497
+ retry). BatchGet neither preserves order nor returns absent keys, so each
498
+ returned item is matched back to its input key by the resolved base-table
499
+ ``PK`` / ``SK`` and misses are filled with an explicit ``None``. Every
500
+ input key is present in the result (a duplicate input key maps once).
501
+ """
502
+ op = spec["operations"][0]
503
+ if op["type"] != "GetItem":
504
+ raise GraphDDBError(
505
+ f"{contract_name}.{method_name}: a batched point read requires a "
506
+ f"GetItem op (a key array coalesces to BatchGetItem), found "
507
+ f"'{op['type']}'."
508
+ )
509
+ select = self._select_from_projection(op)
510
+ entity_meta = self._resolve_entity(op)
511
+ params_spec = spec.get("params", {})
512
+ kc = op["keyCondition"]
513
+ key_attrs = list(kc.keys())
514
+
515
+ # Resolve each input key to its base-table dynamo key once; record the
516
+ # canonical identity ↔ storage marker correspondence (dedup by marker).
517
+ resolved = []
518
+ unique_by_marker: Dict[str, Dict[str, Any]] = {}
519
+ for key in keys:
520
+ validate_params(
521
+ key, params_spec, operation_id=f"{contract_name}.{method_name}"
522
+ )
523
+ plain_key = {attr: resolve_template(tmpl, key) for attr, tmpl in kc.items()}
524
+ marker = serialize_key(plain_key)
525
+ resolved.append((serialize_contract_key(key), plain_key, marker))
526
+ if marker not in unique_by_marker:
527
+ unique_by_marker[marker] = plain_key
528
+
529
+ if len(unique_by_marker) > self._limits.max_items:
530
+ raise LimitExceededError(
531
+ f"{contract_name}.{method_name}: BatchGet needs "
532
+ f"{len(unique_by_marker)} keys, exceeds max_items "
533
+ f"{self._limits.max_items}"
534
+ )
535
+
536
+ raw_by_marker: Dict[str, Dict[str, Any]] = {}
537
+ if unique_by_marker:
538
+ serialized_keys = [
539
+ {attr: self._serializer.serialize(v) for attr, v in plain.items()}
540
+ for plain in unique_by_marker.values()
541
+ ]
542
+ executor = BatchGetExecutor(
543
+ self._client,
544
+ boto_errors=_BOTO_ERRORS,
545
+ max_batch_get_items=self._limits.max_batch_get_items,
546
+ )
547
+ request_extra = self._projection_request(op, select)
548
+ raw_items = executor.get(
549
+ self._physical_table(op["tableName"]), serialized_keys, request_extra
550
+ )
551
+ for raw in raw_items:
552
+ item = self._deserialize(raw)
553
+ marker = serialize_key({attr: item.get(attr) for attr in key_attrs})
554
+ raw_by_marker[marker] = item
555
+
556
+ out: Dict[str, Optional[dict]] = {}
557
+ for key_id, _plain_key, marker in resolved:
558
+ if key_id in out:
559
+ continue # a duplicate input key — already mapped.
560
+ raw = raw_by_marker.get(marker)
561
+ out[key_id] = (
562
+ hydrate_item(raw, select, entity_meta) if raw is not None else None
563
+ )
564
+ return out
565
+
566
+ # ── External Query composition (issue #63) ──────────────────────────────────
567
+
568
+ def _resolve_compositions(
569
+ self,
570
+ contract_name: str,
571
+ method_name: str,
572
+ compose: List[Mapping[str, Any]],
573
+ parents: List[Dict[str, Any]],
574
+ composition_plan: Optional[Mapping[str, Any]] = None,
575
+ ) -> None:
576
+ """Resolve every External Query composition (#63) on a method against the
577
+ already-resolved parent records, **mutating each in place** to attach the
578
+ composed value at the edge's ``as`` property, HONORING the composition plan
579
+ (issue #70b).
580
+
581
+ This is the DataLoader pattern across contracts: for each compose edge the
582
+ bound child key is collected from **all** parent records (via the
583
+ declarative ``from`` paths), then the referenced child contract method is
584
+ resolved **once, batched** — N parents collapse to **one**
585
+ ``BatchGetItem`` (the child is ``point``, enforced by the #60 N+1
586
+ checker), so there is no N+1 fan-out. Each parent then receives its child
587
+ (or an explicit ``None`` when the child key is absent / the item missing).
588
+
589
+ The ``compositionPlan`` (issue #70a) declares which compositions are
590
+ independent (a stage of ``compose[]`` indices) and the in-flight bound.
591
+ Independent compositions write **disjoint** ``as`` properties and each
592
+ issues its own batched child read, so this runtime resolves the
593
+ compositions of a stage **concurrently** under the declared bound (#70b).
594
+ With **no** plan (backward compatibility), the compositions are resolved
595
+ sequentially in declared order — the pre-#70b behavior.
596
+ """
597
+ if not compose or not parents:
598
+ return
599
+ stages = self._composition_stages(composition_plan, len(compose))
600
+ concurrency = self._composition_concurrency(composition_plan)
601
+ for stage in stages:
602
+ stage_edges = [compose[i] for i in stage]
603
+ # Independent compositions of one stage → bounded-concurrent resolve.
604
+ # The helper preserves order and runs a single-edge stage inline.
605
+ map_with_concurrency(
606
+ stage_edges,
607
+ concurrency,
608
+ lambda edge, _i: self._resolve_composition(
609
+ contract_name, method_name, edge, parents
610
+ ),
611
+ )
612
+
613
+ @staticmethod
614
+ def _composition_stages(
615
+ composition_plan: Optional[Mapping[str, Any]], compose_count: int
616
+ ) -> List[List[int]]:
617
+ """The ordered composition stages (indices into ``compose[]``).
618
+
619
+ Reads ``compositionPlan.stages`` (issue #70a) when present, skipping empty
620
+ stages and dropping any out-of-range index defensively. Falls back to one
621
+ stage holding **all** compositions in declared order when the plan is absent
622
+ — note the pre-#70b runtime resolved them sequentially, but a single
623
+ all-in-one stage with a ``concurrency`` of 1 (the plan-less default below)
624
+ is the same sequential resolution; with a real plan the same stage runs
625
+ concurrently. To preserve exact pre-#70b behavior when no plan is present,
626
+ the caller's concurrency falls back to 1 (see
627
+ :meth:`_composition_concurrency`).
628
+ """
629
+ if composition_plan and isinstance(composition_plan.get("stages"), list):
630
+ stages: List[List[int]] = []
631
+ for stage in composition_plan["stages"]:
632
+ indices = [i for i in stage if 0 <= i < compose_count]
633
+ if indices:
634
+ stages.append(indices)
635
+ if stages:
636
+ return stages
637
+ # No plan → one stage with all compositions, resolved sequentially (the
638
+ # plan-less concurrency is 1, so this matches the pre-#70b in-order loop).
639
+ return [list(range(compose_count))]
640
+
641
+ @staticmethod
642
+ def _composition_concurrency(
643
+ composition_plan: Optional[Mapping[str, Any]]
644
+ ) -> int:
645
+ """The declared in-flight bound for composition stages.
646
+
647
+ With a plan, the serialized ``concurrency`` (16). **Without** a plan, ``1``
648
+ — so the fallback single all-in-one stage resolves sequentially, preserving
649
+ the exact pre-#70b behavior for a plan-less spec (backward compatibility).
650
+ """
651
+ plan = composition_plan or {}
652
+ concurrency = plan.get("concurrency")
653
+ if isinstance(concurrency, int) and concurrency > 0:
654
+ return concurrency
655
+ return 1
656
+
657
+ def _resolve_composition(
658
+ self,
659
+ contract_name: str,
660
+ method_name: str,
661
+ edge: Mapping[str, Any],
662
+ parents: List[Dict[str, Any]],
663
+ ) -> None:
664
+ """Resolve one compose edge across all parents (one batched child read)."""
665
+ as_prop = edge["as"]
666
+ if edge.get("resolution") != "point":
667
+ # The #60 N+1 checker rejects a `range` child at build; runtime
668
+ # backstop so the executor never silently fans out.
669
+ raise GraphDDBError(
670
+ f"{contract_name}.{method_name}: composed child '{as_prop}' resolves "
671
+ f"to a 'range' method, but an External Query child must be 'point' so "
672
+ f"it coalesces to one BatchGetItem across all parent records."
673
+ )
674
+ child_contract = edge["contract"]
675
+ child_method_name = edge["method"]
676
+ child_method = self._resolve_contract_query_method(
677
+ child_contract, child_method_name
678
+ )
679
+ child_op_spec = self._contract_query_op(
680
+ child_contract, child_method_name, child_method
681
+ )
682
+ bind: Mapping[str, str] = edge.get("bind", {})
683
+
684
+ # Collect the bound child key from each parent record. A parent whose
685
+ # bound path is absent / None cannot resolve a child → explicit None.
686
+ bound: List[tuple] = [] # (parent, child_key, key_id)
687
+ child_keys: List[Dict[str, Any]] = []
688
+ for parent in parents:
689
+ child_key: Dict[str, Any] = {}
690
+ complete = True
691
+ for field, path in bind.items():
692
+ value = _read_from_path(parent, path)
693
+ if value is None:
694
+ complete = False
695
+ break
696
+ child_key[field] = value
697
+ if not complete:
698
+ parent[as_prop] = None
699
+ continue
700
+ bound.append((parent, child_key, serialize_contract_key(child_key)))
701
+ child_keys.append(child_key)
702
+
703
+ if not child_keys:
704
+ return
705
+
706
+ # ONE batched child read across every parent's bound key (point →
707
+ # BatchGetItem, reusing the keyed-batch path). N parents → 1 request.
708
+ child_results = self._execute_point_batch(
709
+ child_contract, child_method_name, child_op_spec, child_keys
710
+ )
711
+
712
+ for parent, _child_key, key_id in bound:
713
+ parent[as_prop] = child_results.get(key_id)
714
+
715
+ def execute_command(
716
+ self,
717
+ command_id: str,
718
+ params: Mapping[str, Any],
719
+ options: Optional[Mapping[str, Any]] = None,
720
+ ) -> None:
721
+ spec = self._commands.get(command_id)
722
+ if spec is None:
723
+ raise CommandNotFoundError(f"unknown command '{command_id}'")
724
+
725
+ validate_params(params, spec.get("params", {}), operation_id=command_id)
726
+
727
+ op_type = spec["type"]
728
+ if op_type == "PutItem":
729
+ self._run_put_item(command_id, spec, params)
730
+ elif op_type == "UpdateItem":
731
+ self._run_update_item(command_id, spec, params)
732
+ elif op_type == "DeleteItem":
733
+ self._run_delete_item(command_id, spec, params)
734
+ else: # pragma: no cover - schema only allows the three above
735
+ raise GraphDDBError(f"{command_id}: unknown command type '{op_type}'")
736
+
737
+ def execute_transaction(
738
+ self,
739
+ transaction_id: str,
740
+ params: Mapping[str, Any],
741
+ ) -> None:
742
+ """Execute a declarative transaction (issue #46).
743
+
744
+ Validates params (including array ``forEach`` sources), expands the spec
745
+ into a ``TransactWriteItems`` request (≤25 after expansion), and runs it
746
+ via boto3 ``transact_write_items``. A failed transaction
747
+ (``TransactionCanceledException``) is wrapped in
748
+ :class:`OperationExecutionError`. A transaction that expands to zero items
749
+ is a no-op.
750
+ """
751
+ spec = self._transactions.get(transaction_id)
752
+ if spec is None:
753
+ raise TransactionNotFoundError(f"unknown transaction '{transaction_id}'")
754
+
755
+ validate_params(params, spec.get("params", {}), operation_id=transaction_id)
756
+
757
+ expander = TransactionExpander(self)
758
+ transact_items = expander.expand(spec, params)
759
+
760
+ if len(transact_items) > MAX_TRANSACT_ITEMS:
761
+ raise LimitExceededError(
762
+ f"{transaction_id}: transaction expanded to {len(transact_items)} "
763
+ f"items, exceeds the DynamoDB TransactWriteItems limit of "
764
+ f"{MAX_TRANSACT_ITEMS}"
765
+ )
766
+ if not transact_items:
767
+ return
768
+
769
+ self._call(
770
+ transaction_id,
771
+ self._client.transact_write_items,
772
+ {"TransactItems": transact_items},
773
+ )
774
+
775
+ # ── command-contract execution (issue #64) ──────────────────────────────
776
+
777
+ def execute_command_method(
778
+ self,
779
+ contract_name: str,
780
+ method_name: str,
781
+ key_or_keys: Any,
782
+ params: Optional[Mapping[str, Any]] = None,
783
+ ) -> Optional[dict]:
784
+ """Execute one CQRS **command**-contract method (issue #64; proposal
785
+ "Command Contract" + "Command Definition").
786
+
787
+ A command method maps a **single key** to one write op (``put`` /
788
+ ``update`` / ``delete``) and an **array of keys** to a batched write per
789
+ the method's declared mode:
790
+
791
+ - ``single`` key → the one referenced write op in ``commands``;
792
+ - ``keys[]`` + ``transact`` → one ``TransactWriteItems`` (the synthesized
793
+ per-key ``forEach`` transaction; **atomic**, ≤25, condition-capable). An
794
+ array of **>25 keys is rejected** — an atomic transaction cannot be split.
795
+ - ``keys[]`` + ``batchWrite`` → a ``BatchWriteItem`` (**non-atomic**, **no
796
+ conditions**), chunked ≤25 per request with ``UnprocessedItems`` retry.
797
+
798
+ ``params`` are the mutation values shared across every key (the body's
799
+ ``params`` argument). For each key the runtime merges ``{**key, **params}``
800
+ to drive the underlying single write op, exactly as the TS runtime does, so
801
+ the two produce **identical effects** for the same SSoT.
802
+
803
+ :raises ContractNotFoundError: unknown contract / method, or a query
804
+ contract was addressed.
805
+ :raises ContractArityError: a key array fed to a method with no batched
806
+ form.
807
+ :raises LimitExceededError: a >25-key ``transact`` batch.
808
+ """
809
+ method = self._resolve_contract_command_method(contract_name, method_name)
810
+ shared = dict(params or {})
811
+
812
+ if isinstance(key_or_keys, list):
813
+ self._execute_command_batch(
814
+ contract_name, method_name, method, key_or_keys, shared
815
+ )
816
+ return None
817
+
818
+ # Single key → the referenced write surface, driven by key + params.
819
+ single = method["single"]
820
+ merged = {**dict(key_or_keys), **shared}
821
+ mode = single.get("mode", "op")
822
+ if mode == "transaction":
823
+ # #90: a MULTI-fragment mutation composes 2+ write fragments into ONE
824
+ # atomic `TransactWriteItems`. The serializer emits a no-`forEach`
825
+ # transaction (a fixed composed write set) whose item templates are the
826
+ # same shared-param `{param}` bindings a single-op write uses — the
827
+ # cross-fragment data dependencies were resolved at compile time into
828
+ # shared-param references. Execute it atomically; a condition failure on
829
+ # any fragment rolls back the WHOLE transaction (DynamoDB atomicity),
830
+ # identically to the TS runtime's `executeTransactWrites`.
831
+ self.execute_transaction(single["transaction"], merged)
832
+ else:
833
+ command_id = single["operation"]
834
+ self.execute_command(command_id, merged)
835
+
836
+ # #83: a `mutation`-derived method that declares a return projection
837
+ # resolves its result as a CONSISTENT read-back of the written entity. After
838
+ # the write commits, GetItem the written entity's primary key with
839
+ # ConsistentRead and project the declared `returnSelection` via the EXISTING
840
+ # read-projection machinery — the UNIFORM return mechanism for both a single
841
+ # op and the future #90 transaction (a TransactWriteItems returns no item
842
+ # image), so TS and Python return an IDENTICAL projected item.
843
+ return_selection = method.get("returnSelection")
844
+ if return_selection:
845
+ return self._read_back_projection(
846
+ contract_name, method_name, merged
847
+ )
848
+ return None
849
+
850
+ def _read_back_projection(
851
+ self,
852
+ contract_name: str,
853
+ method_name: str,
854
+ params: Mapping[str, Any],
855
+ ) -> Optional[dict]:
856
+ """Run the synthesized consistent read-back query for a command method.
857
+
858
+ The serializer (#83) emits a base-table ``GetItem`` query named
859
+ ``<Contract>__<method>__readback`` projecting the return-selection fields;
860
+ its ``keyCondition`` templates reference the written entity's primary-key
861
+ fields (``{field}``), which are present in the merged ``params`` (the key +
862
+ shared input). Resolve it with ``ConsistentRead`` through the proven
863
+ ``_run_get_item`` path so the projection is request-identical to the TS
864
+ runtime's ``executeQuery`` read-back.
865
+ """
866
+ readback_id = f"{contract_name}__{method_name}__readback"
867
+ spec = self._queries.get(readback_id)
868
+ if spec is None:
869
+ raise GraphDDBError(
870
+ f"{contract_name}.{method_name}: declares a return projection but the "
871
+ f"read-back query '{readback_id}' is not present in `queries`."
872
+ )
873
+ op = spec["operations"][0]
874
+ if op["type"] != "GetItem":
875
+ raise GraphDDBError(
876
+ f"{contract_name}.{method_name}: the read-back op '{readback_id}' must "
877
+ f"be a base-table GetItem, found '{op['type']}'."
878
+ )
879
+ select = self._select_from_projection(op)
880
+ entity_meta = self._resolve_entity(op)
881
+ return self._run_get_item(
882
+ f"{contract_name}.{method_name}__readback",
883
+ op,
884
+ params,
885
+ select,
886
+ entity_meta,
887
+ consistent_read=True,
888
+ )
889
+
890
+ def _execute_command_batch(
891
+ self,
892
+ contract_name: str,
893
+ method_name: str,
894
+ method: Mapping[str, Any],
895
+ keys: List[Mapping[str, Any]],
896
+ shared: Mapping[str, Any],
897
+ ) -> None:
898
+ """Apply a command method's array form per its declared batch target."""
899
+ batch = method.get("batch")
900
+ label = f"{contract_name}.{method_name}"
901
+ if batch is None:
902
+ raise ContractArityError(
903
+ f"command method '{label}' was called with an array of keys, but it "
904
+ f"declares no batched-write form. Declare a 'transact' or 'batchWrite' "
905
+ f"batch on the method, or call it with a single key."
906
+ )
907
+ mode = batch.get("mode")
908
+ if mode == "transaction":
909
+ # The synthesized transaction expands one item per key (≤25, atomic). The
910
+ # element shape is the contract Key fields; the shared params are scalars.
911
+ self.execute_transaction(
912
+ batch["transaction"], {**dict(shared), "keys": [dict(k) for k in keys]}
913
+ )
914
+ return
915
+ if mode == "batchWrite":
916
+ self._execute_batch_write(label, batch["operation"], keys, shared)
917
+ return
918
+ raise GraphDDBError( # pragma: no cover - serializer only emits the two above
919
+ f"{label}: unknown batch resolution mode '{mode}'"
920
+ )
921
+
922
+ def _execute_batch_write(
923
+ self,
924
+ label: str,
925
+ command_id: str,
926
+ keys: List[Mapping[str, Any]],
927
+ shared: Mapping[str, Any],
928
+ ) -> None:
929
+ """Apply a ``BatchWriteItem`` over the per-key write op (no conditions).
930
+
931
+ Reuses :class:`BatchWriteExecutor` (chunk ≤25, ``UnprocessedItems`` retry).
932
+ DynamoDB's ``BatchWriteItem`` supports only ``PutRequest`` / ``DeleteRequest``
933
+ — a command whose single-key op is an ``UpdateItem`` cannot resolve its
934
+ array form to a ``BatchWriteItem`` (it must declare ``transact``).
935
+ """
936
+ spec = self._commands.get(command_id)
937
+ if spec is None:
938
+ raise ContractNotFoundError(
939
+ f"{label}: referenced write op '{command_id}' is not present in "
940
+ f"`commands`."
941
+ )
942
+ op_type = spec["type"]
943
+ if op_type == "UpdateItem":
944
+ raise GraphDDBError(
945
+ f"{label}: 'batchWrite' only supports put / delete (DynamoDB's "
946
+ f"BatchWriteItem has no Update request). Declare a 'transact' batch "
947
+ f"for a batched update."
948
+ )
949
+ requests: List[Dict[str, Any]] = []
950
+ for key in keys:
951
+ params = {**dict(key), **dict(shared)}
952
+ validate_params(params, spec.get("params", {}), operation_id=command_id)
953
+ if op_type == "PutItem":
954
+ item = {
955
+ field: self._serializer.serialize(resolve_template(tmpl, params))
956
+ for field, tmpl in spec.get("item", {}).items()
957
+ }
958
+ self._add_key_attributes(spec["entity"], item, params)
959
+ requests.append({"PutRequest": {"Item": item}})
960
+ else: # DeleteItem
961
+ dynamo_key = {
962
+ attr: self._serializer.serialize(resolve_template(tmpl, params))
963
+ for attr, tmpl in spec["keyCondition"].items()
964
+ }
965
+ requests.append({"DeleteRequest": {"Key": dynamo_key}})
966
+
967
+ executor = BatchWriteExecutor(self._client, boto_errors=_BOTO_ERRORS)
968
+ executor.write(self._physical_table(spec["tableName"]), requests)
969
+
970
+ def _resolve_contract_command_method(
971
+ self, contract_name: str, method_name: str
972
+ ) -> Mapping[str, Any]:
973
+ contract = self._contracts.get(contract_name)
974
+ if contract is None:
975
+ available = ", ".join(sorted(self._contracts.keys())) or "(none)"
976
+ raise ContractNotFoundError(
977
+ f"unknown contract '{contract_name}'. Available: {available}"
978
+ )
979
+ if contract.get("kind") != "command":
980
+ raise ContractNotFoundError(
981
+ f"contract '{contract_name}' is a '{contract.get('kind')}' contract; "
982
+ f"execute_command_method only runs command contracts (queries use "
983
+ f"execute_query_method)."
984
+ )
985
+ methods = contract.get("methods", {})
986
+ method = methods.get(method_name)
987
+ if method is None:
988
+ available = ", ".join(sorted(methods.keys())) or "(none)"
989
+ raise ContractNotFoundError(
990
+ f"contract '{contract_name}' has no command method '{method_name}'. "
991
+ f"Available: {available}"
992
+ )
993
+ return method
994
+
995
+ def explain(self, query_id: str, params: Mapping[str, Any]) -> dict:
996
+ """Return the resolved operation list for a query without touching DynamoDB.
997
+
998
+ Templates are resolved with the supplied ``params`` (``{result.*}``
999
+ placeholders are left intact — they are bound from prior results only at
1000
+ execution time). The shape mirrors the operations spec so callers can see
1001
+ exactly which physical operations a query would issue.
1002
+ """
1003
+ spec = self._queries.get(query_id)
1004
+ if spec is None:
1005
+ raise QueryNotFoundError(f"unknown query '{query_id}'")
1006
+ validate_params(params, spec.get("params", {}), operation_id=query_id)
1007
+
1008
+ resolved_ops: List[Dict[str, Any]] = []
1009
+ for op in spec.get("operations", []):
1010
+ resolved: Dict[str, Any] = {
1011
+ "type": op["type"],
1012
+ "tableName": self._physical_table(op["tableName"]),
1013
+ "keyCondition": {
1014
+ attr: self._resolve_partial(tmpl, params)
1015
+ for attr, tmpl in op.get("keyCondition", {}).items()
1016
+ },
1017
+ "projection": list(op.get("projection", [])),
1018
+ "resultPath": op.get("resultPath", "$"),
1019
+ }
1020
+ if op.get("indexName"):
1021
+ resolved["indexName"] = op["indexName"]
1022
+ if op.get("rangeCondition"):
1023
+ rc = op["rangeCondition"]
1024
+ resolved["rangeCondition"] = {
1025
+ "operator": rc["operator"],
1026
+ "key": rc["key"],
1027
+ "value": self._resolve_partial(rc["value"], params),
1028
+ }
1029
+ if op.get("limit") is not None:
1030
+ resolved["limit"] = op["limit"]
1031
+ if op.get("filter"):
1032
+ resolved["filter"] = op["filter"]
1033
+ if op.get("sourceField"):
1034
+ resolved["sourceField"] = op["sourceField"]
1035
+ resolved_ops.append(resolved)
1036
+
1037
+ return {
1038
+ "queryId": query_id,
1039
+ "cardinality": spec.get("cardinality"),
1040
+ "operations": resolved_ops,
1041
+ }
1042
+
1043
+ # ── limits ──────────────────────────────────────────────────────────────────
1044
+
1045
+ def _enforce_operation_limits(
1046
+ self, query_id: str, operations: List[Dict[str, Any]]
1047
+ ) -> None:
1048
+ if not operations:
1049
+ raise GraphDDBError(f"{query_id}: query has no operations")
1050
+ if len(operations) > self._limits.max_operations:
1051
+ raise LimitExceededError(
1052
+ f"{query_id}: query needs {len(operations)} operations, exceeds "
1053
+ f"max_operations {self._limits.max_operations}"
1054
+ )
1055
+ depth = self._max_relation_depth(operations)
1056
+ if depth > self._limits.max_depth:
1057
+ raise LimitExceededError(
1058
+ f"{query_id}: relation traversal depth {depth} exceeds max_depth "
1059
+ f"{self._limits.max_depth}"
1060
+ )
1061
+
1062
+ @staticmethod
1063
+ def _max_relation_depth(operations: List[Dict[str, Any]]) -> int:
1064
+ """Deepest relation nesting, counting each ``.items``/single-value hop.
1065
+
1066
+ The root op (``$``) is depth 0; ``$.groups.items`` is depth 1;
1067
+ ``$.groups.items.group`` is depth 2; ``$.groups.items.group.permissions.items``
1068
+ is depth 3 — matching the TS ``validateDepth`` currentDepth counting where
1069
+ each relation level increments depth.
1070
+ """
1071
+ max_depth = 0
1072
+ for op in operations[1:]:
1073
+ path = op.get("resultPath", "$")
1074
+ if path in ("$", ""):
1075
+ continue
1076
+ tokens = path[2:].split(".")
1077
+ # Each non-`items` token is one relation hop.
1078
+ depth = sum(1 for t in tokens if t != "items")
1079
+ max_depth = max(max_depth, depth)
1080
+ return max_depth
1081
+
1082
+ # ── root operation ──────────────────────────────────────────────────────────
1083
+
1084
+ def _run_root(
1085
+ self,
1086
+ query_id: str,
1087
+ op: Mapping[str, Any],
1088
+ params: Mapping[str, Any],
1089
+ options: Mapping[str, Any],
1090
+ cardinality: Optional[str],
1091
+ ) -> Optional[dict]:
1092
+ select = self._select_from_projection(op)
1093
+ entity_meta = self._resolve_entity(op)
1094
+ op_type = op["type"]
1095
+
1096
+ if op_type == "GetItem":
1097
+ return self._run_get_item(query_id, op, params, select, entity_meta)
1098
+
1099
+ if op_type == "Query":
1100
+ connection = self._run_query(
1101
+ query_id, op, params, select, entity_meta, options
1102
+ )
1103
+ # A `defineQuery` root with declared cardinality 'one' is a single
1104
+ # entity object regardless of whether relations are assembled onto it:
1105
+ # take the first matched item (None if absent). This matches the
1106
+ # generated flat result type and the TS live executor, which returns
1107
+ # a single object for a cardinality-one Query. A 'many' root (or an
1108
+ # absent cardinality, which defaults to 'many') keeps the connection
1109
+ # shape `{items, cursor}`.
1110
+ wants_single = cardinality == "one"
1111
+ if wants_single:
1112
+ items = connection["items"]
1113
+ return items[0] if items else None
1114
+ return connection
1115
+
1116
+ raise GraphDDBError(
1117
+ f"{query_id}: unsupported root read operation '{op_type}'"
1118
+ )
1119
+
1120
+ # ── relation operations ───────────────────────────────────────────────────────
1121
+
1122
+ def _run_relation_query(
1123
+ self,
1124
+ query_id: str,
1125
+ op: Mapping[str, Any],
1126
+ source_values: Mapping[str, Any],
1127
+ ) -> Dict[str, Any]:
1128
+ select = self._select_from_projection(op)
1129
+ entity_meta = self._resolve_entity(op)
1130
+ ctx = self._result_context(source_values)
1131
+ return self._run_query(
1132
+ query_id, op, ctx, select, entity_meta, options={}, is_relation=True
1133
+ )
1134
+
1135
+ def _run_relation_batch_get(
1136
+ self,
1137
+ query_id: str,
1138
+ op: Mapping[str, Any],
1139
+ source_values: List[Mapping[str, Any]],
1140
+ ) -> Dict[str, str]:
1141
+ select = self._select_from_projection(op)
1142
+ entity_meta = self._resolve_entity(op)
1143
+
1144
+ # Build (deduped) plain keys + their serialized markers.
1145
+ plain_keys: List[Dict[str, Any]] = []
1146
+ seen: set = set()
1147
+ for sv in source_values:
1148
+ ctx = self._result_context(sv)
1149
+ plain_key = {
1150
+ attr: resolve_template(tmpl, ctx)
1151
+ for attr, tmpl in op["keyCondition"].items()
1152
+ }
1153
+ marker = serialize_key(plain_key)
1154
+ if marker in seen:
1155
+ continue
1156
+ seen.add(marker)
1157
+ plain_keys.append(plain_key)
1158
+
1159
+ # The executor chunks at max_batch_get_items; a hard ceiling on the total
1160
+ # deduped key count also fails a runaway fan-out fast rather than issuing
1161
+ # an unbounded number of chunks.
1162
+ if len(plain_keys) > self._limits.max_items:
1163
+ raise LimitExceededError(
1164
+ f"{query_id}: BatchGet needs {len(plain_keys)} keys, exceeds "
1165
+ f"max_items {self._limits.max_items}"
1166
+ )
1167
+
1168
+ serialized_keys = [
1169
+ {attr: self._serializer.serialize(v) for attr, v in key.items()}
1170
+ for key in plain_keys
1171
+ ]
1172
+
1173
+ executor = BatchGetExecutor(
1174
+ self._client,
1175
+ boto_errors=_BOTO_ERRORS,
1176
+ max_batch_get_items=self._limits.max_batch_get_items,
1177
+ )
1178
+ request_extra = self._projection_request(op, select)
1179
+ raw_items = executor.get(
1180
+ self._physical_table(op["tableName"]), serialized_keys, request_extra
1181
+ )
1182
+
1183
+ key_attrs = list(op["keyCondition"].keys())
1184
+ key_to_item: Dict[str, str] = {}
1185
+ for raw in raw_items:
1186
+ item = self._deserialize(raw)
1187
+ marker = serialize_key({attr: item.get(attr) for attr in key_attrs})
1188
+ key_to_item[marker] = hydrate_item(item, select, entity_meta)
1189
+ return key_to_item
1190
+
1191
+ def _relation_key_marker(
1192
+ self, op: Mapping[str, Any], source_values: Mapping[str, Any]
1193
+ ) -> str:
1194
+ ctx = self._result_context(source_values)
1195
+ plain_key = {
1196
+ attr: resolve_template(tmpl, ctx)
1197
+ for attr, tmpl in op["keyCondition"].items()
1198
+ }
1199
+ return serialize_key(plain_key)
1200
+
1201
+ @staticmethod
1202
+ def _result_context(source_values: Mapping[str, Any]) -> Dict[str, Any]:
1203
+ """Map ``{source_field: value}`` to the ``result.<field>`` template names."""
1204
+ return {f"result.{field}": value for field, value in source_values.items()}
1205
+
1206
+ # ── helpers: planning / resolution ──────────────────────────────────────────
1207
+
1208
+ def _physical_table(self, logical: str) -> str:
1209
+ return self._table_mapping.get(logical, logical)
1210
+
1211
+ def _select_from_projection(self, op: Mapping[str, Any]) -> Dict[str, Any]:
1212
+ return {field: True for field in op.get("projection", [])}
1213
+
1214
+ def _resolve_partial(self, template: str, params: Mapping[str, Any]) -> str:
1215
+ """Resolve ``{param}`` placeholders, leaving ``{result.*}`` intact (explain)."""
1216
+
1217
+ def repl(match: "re.Match[str]") -> str:
1218
+ name = match.group(0)[1:-1]
1219
+ if name.startswith("result."):
1220
+ return match.group(0)
1221
+ return str(params.get(name, match.group(0)))
1222
+
1223
+ return _PLACEHOLDER_RE.sub(repl, template)
1224
+
1225
+ def _resolve_entity(self, op: Mapping[str, Any]) -> Dict[str, Any]:
1226
+ """Find the manifest entity a read operation targets, for hydration.
1227
+
1228
+ Strict, insertion-order independent matching (issue #45 follow-up). A
1229
+ candidate entity must:
1230
+
1231
+ 1. live on the operation's logical table;
1232
+ 2. have a key/GSI whose placeholder-normalized PK template matches the
1233
+ operation's PK template;
1234
+ 3. agree on the sort-key predicate:
1235
+ - explicit SK in the keyCondition → equal to the entity's SK template;
1236
+ - ``begins_with`` range on the SK → the entity's SK template must start
1237
+ with the range value's literal prefix;
1238
+ - no SK predicate (a base-table partition Query) → the entity must
1239
+ carry **all** projected fields, which disambiguates same-prefix
1240
+ entities sharing a partition (e.g. Group vs GroupMembership vs
1241
+ Permission under ``GROUP#``). Among several such candidates a unique
1242
+ superset match wins; otherwise the match is rejected so hydration
1243
+ never silently picks the wrong entity (and drops datetime formats).
1244
+
1245
+ Returns an empty meta (no formats) only when nothing matches.
1246
+ """
1247
+ table = op["tableName"]
1248
+ index_name = op.get("indexName")
1249
+ kc = op.get("keyCondition", {})
1250
+ projection = set(op.get("projection", []))
1251
+
1252
+ candidates: List[Dict[str, Any]] = []
1253
+ for entity_meta in self._entities.values():
1254
+ if entity_meta.get("table") != table:
1255
+ continue
1256
+ if index_name is None:
1257
+ key = entity_meta.get("key")
1258
+ if not key:
1259
+ continue
1260
+ # The segment key model (#51) makes pkTemplate self-contained
1261
+ # (it carries the entity discriminator), so the entity prefix is
1262
+ # no longer prepended.
1263
+ pk_tmpl = key.get("pkTemplate") or ""
1264
+ sk_tmpl = key.get("skTemplate")
1265
+ match = self._key_match_kind(kc, op, "PK", pk_tmpl, "SK", sk_tmpl)
1266
+ else:
1267
+ match = None
1268
+ for gsi in entity_meta.get("gsis", []):
1269
+ if gsi.get("indexName") != index_name:
1270
+ continue
1271
+ pk_attr = f"{index_name}PK"
1272
+ sk_attr = f"{index_name}SK"
1273
+ match = self._key_match_kind(
1274
+ kc,
1275
+ op,
1276
+ pk_attr,
1277
+ gsi.get("pkTemplate"),
1278
+ sk_attr,
1279
+ gsi.get("skTemplate"),
1280
+ )
1281
+ if match:
1282
+ break
1283
+ if not match:
1284
+ continue
1285
+ candidates.append((match, entity_meta))
1286
+
1287
+ if not candidates:
1288
+ return {"fields": {}}
1289
+
1290
+ # Prefer an exact sort-key predicate match (explicit SK or begins_with);
1291
+ # these are unambiguous.
1292
+ exact = [meta for (kind, meta) in candidates if kind == "exact"]
1293
+ if len(exact) == 1:
1294
+ return exact[0]
1295
+ if len(exact) > 1:
1296
+ exact = self._disambiguate_by_projection(exact, projection)
1297
+ if len(exact) == 1:
1298
+ return exact[0]
1299
+
1300
+ # No SK predicate (partition Query): disambiguate by projection coverage.
1301
+ partition = [meta for (kind, meta) in candidates if kind == "partition"]
1302
+ pool = exact or partition or [meta for (_k, meta) in candidates]
1303
+ narrowed = self._disambiguate_by_projection(pool, projection)
1304
+ if len(narrowed) == 1:
1305
+ return narrowed[0]
1306
+ # Ambiguous: refuse to guess so datetime restoration is not silently
1307
+ # taken from the wrong entity.
1308
+ return {"fields": {}}
1309
+
1310
+ @staticmethod
1311
+ def _disambiguate_by_projection(
1312
+ candidates: List[Dict[str, Any]], projection: set
1313
+ ) -> List[Dict[str, Any]]:
1314
+ if not projection:
1315
+ return candidates
1316
+ covering = [
1317
+ meta
1318
+ for meta in candidates
1319
+ if projection.issubset(set(meta.get("fields", {}).keys()))
1320
+ ]
1321
+ return covering or candidates
1322
+
1323
+ def _key_match_kind(
1324
+ self,
1325
+ kc: Mapping[str, Any],
1326
+ op: Mapping[str, Any],
1327
+ pk_attr: str,
1328
+ pk_tmpl: Optional[str],
1329
+ sk_attr: str,
1330
+ sk_tmpl: Optional[str],
1331
+ ) -> Optional[str]:
1332
+ """Return 'exact' / 'partition' if the entity key matches, else None.
1333
+
1334
+ 'exact' — the PK matches and the SK predicate (equality or
1335
+ begins_with) aligns with the entity's SK template.
1336
+ 'partition' — the PK matches and the op has no SK predicate (so the SK is
1337
+ undetermined; the caller disambiguates by projection).
1338
+ """
1339
+ if _normalize_template(kc.get(pk_attr)) != _normalize_template(pk_tmpl):
1340
+ return None
1341
+
1342
+ op_sk = kc.get(sk_attr)
1343
+ if op_sk is not None:
1344
+ return (
1345
+ "exact"
1346
+ if _normalize_template(op_sk) == _normalize_template(sk_tmpl)
1347
+ else None
1348
+ )
1349
+
1350
+ rng = op.get("rangeCondition")
1351
+ if rng is not None and rng.get("key") == sk_attr:
1352
+ if sk_tmpl is None:
1353
+ return None
1354
+ # begins_with: the entity SK template must start with the range's
1355
+ # literal prefix (everything up to the first placeholder).
1356
+ literal_prefix = _PLACEHOLDER_RE.split(rng.get("value", ""))[0]
1357
+ if literal_prefix and not sk_tmpl.startswith(literal_prefix):
1358
+ return None
1359
+ return "exact"
1360
+
1361
+ # No SK predicate at all.
1362
+ return "partition"
1363
+
1364
+ # ── helpers: read execution ────────────────────────────────────────────────
1365
+
1366
+ def _projection_request(
1367
+ self, op: Mapping[str, Any], select: Mapping[str, Any]
1368
+ ) -> Dict[str, Any]:
1369
+ """Build ProjectionExpression / names for a BatchGet, including key attrs.
1370
+
1371
+ Key attributes (PK/SK or GSI keys) are added so items can be matched back
1372
+ to parents, mirroring ``planBatchGetForQueryKeys`` which adds the key
1373
+ fields to the projection.
1374
+ """
1375
+ fields = [f for f, v in select.items() if v is True]
1376
+ # Always project the key attributes used for matching.
1377
+ for attr in op.get("keyCondition", {}).keys():
1378
+ if attr not in fields:
1379
+ fields.append(attr)
1380
+ if not fields:
1381
+ return {}
1382
+ names = {f"#p{i}": f for i, f in enumerate(fields)}
1383
+ return {
1384
+ "ProjectionExpression": ", ".join(names.keys()),
1385
+ "ExpressionAttributeNames": names,
1386
+ }
1387
+
1388
+ def _run_get_item(
1389
+ self,
1390
+ query_id: str,
1391
+ op: Mapping[str, Any],
1392
+ params: Mapping[str, Any],
1393
+ select: Mapping[str, Any],
1394
+ entity_meta: Mapping[str, Any],
1395
+ consistent_read: bool = False,
1396
+ ) -> Optional[dict]:
1397
+ key = {
1398
+ attr: self._serializer.serialize(resolve_template(tmpl, params))
1399
+ for attr, tmpl in op["keyCondition"].items()
1400
+ }
1401
+ request: Dict[str, Any] = {
1402
+ "TableName": self._physical_table(op["tableName"]),
1403
+ "Key": key,
1404
+ }
1405
+ # Strongly-consistent point read on the base table (issue #65). The TS
1406
+ # runtime sets ``ConsistentRead`` on a base-table GetItem when the contract
1407
+ # method's ``consistentRead`` retrieval option is truthy
1408
+ # (``src/executor/executor.ts`` ``executeGetItem``); wire it here so the two
1409
+ # runtimes issue the **identical** GetItem request — closing the parity gap
1410
+ # surfaced by #62's audit (Python previously ignored ``consistentRead``).
1411
+ if consistent_read:
1412
+ request["ConsistentRead"] = True
1413
+ resp = self._call(query_id, self._client.get_item, request)
1414
+ raw = resp.get("Item")
1415
+ if raw is None:
1416
+ return None
1417
+ item = self._deserialize(raw)
1418
+ return hydrate_item(item, select, entity_meta)
1419
+
1420
+ def _run_query(
1421
+ self,
1422
+ query_id: str,
1423
+ op: Mapping[str, Any],
1424
+ params: Mapping[str, Any],
1425
+ select: Mapping[str, Any],
1426
+ entity_meta: Mapping[str, Any],
1427
+ options: Mapping[str, Any],
1428
+ is_relation: bool = False,
1429
+ ) -> Dict[str, Any]:
1430
+ names: Dict[str, str] = {}
1431
+ values: Dict[str, Any] = {}
1432
+ clauses: List[str] = []
1433
+ kc = op["keyCondition"]
1434
+
1435
+ for i, (attr, tmpl) in enumerate(kc.items()):
1436
+ n = f"#k{i}"
1437
+ v = f":k{i}"
1438
+ names[n] = attr
1439
+ values[v] = self._serializer.serialize(resolve_template(tmpl, params))
1440
+ clauses.append(f"{n} = {v}")
1441
+
1442
+ rng = op.get("rangeCondition")
1443
+ if rng is not None:
1444
+ n = "#kr"
1445
+ v = ":kr"
1446
+ names[n] = rng["key"]
1447
+ values[v] = self._serializer.serialize(
1448
+ resolve_template(rng["value"], params)
1449
+ )
1450
+ if rng["operator"] == "begins_with":
1451
+ clauses.append(f"begins_with({n}, {v})")
1452
+ else: # pragma: no cover - only begins_with is specified
1453
+ raise GraphDDBError(f"unsupported range operator '{rng['operator']}'")
1454
+
1455
+ request: Dict[str, Any] = {
1456
+ "TableName": self._physical_table(op["tableName"]),
1457
+ "KeyConditionExpression": " AND ".join(clauses),
1458
+ "ExpressionAttributeNames": names,
1459
+ "ExpressionAttributeValues": values,
1460
+ }
1461
+ if op.get("indexName"):
1462
+ request["IndexName"] = op["indexName"]
1463
+
1464
+ # Strongly-consistent read on a base-table Query (issue #65). The TS
1465
+ # planner (``src/planner/planner.ts``) sets ``ConsistentRead`` only for a
1466
+ # primary-key (base-table) query, so we mirror that for the valid case:
1467
+ # honor ``consistentRead`` only when the op targets no secondary index. On a
1468
+ # GSI-keyed read DynamoDB forbids a consistent read; TS *rejects* the
1469
+ # combination with a hard error while this runtime drops the flag (a
1470
+ # pre-existing #71 GSI-surface divergence, out of #65 scope). Every
1471
+ # reachable base-table point Query is request-identical to TS.
1472
+ if options.get("consistentRead") and not op.get("indexName"):
1473
+ request["ConsistentRead"] = True
1474
+
1475
+ limit = op.get("limit")
1476
+ if limit is not None:
1477
+ if limit > self._limits.max_items:
1478
+ raise LimitExceededError(
1479
+ f"{query_id}: limit {limit} exceeds max_items {self._limits.max_items}"
1480
+ )
1481
+ request["Limit"] = limit
1482
+
1483
+ filter_spec = op.get("filter")
1484
+ if filter_spec and filter_spec.get("declarative"):
1485
+ compiled = compile_filter(filter_spec["declarative"], self._serializer)
1486
+ if compiled:
1487
+ request["FilterExpression"] = compiled["FilterExpression"]
1488
+ names.update(compiled["ExpressionAttributeNames"])
1489
+ values.update(compiled["ExpressionAttributeValues"])
1490
+
1491
+ cursor = options.get("cursor")
1492
+ if cursor:
1493
+ request["ExclusiveStartKey"] = self._serialize_key(decode_cursor(cursor))
1494
+
1495
+ resp = self._call(query_id, self._client.query, request)
1496
+ raw_items = resp.get("Items", [])
1497
+ items = [
1498
+ hydrate_item(self._deserialize(raw), select, entity_meta)
1499
+ for raw in raw_items
1500
+ ]
1501
+ if len(items) > self._limits.max_items:
1502
+ raise LimitExceededError(
1503
+ f"{query_id}: returned {len(items)} items, exceeds max_items "
1504
+ f"{self._limits.max_items}"
1505
+ )
1506
+
1507
+ next_cursor: Optional[str] = None
1508
+ lek = resp.get("LastEvaluatedKey")
1509
+ if lek:
1510
+ next_cursor = encode_cursor(self._deserialize(lek))
1511
+
1512
+ return {"items": items, "cursor": next_cursor}
1513
+
1514
+ # ── helpers: write execution ─────────────────────────────────────────────────
1515
+
1516
+ def _run_put_item(
1517
+ self, command_id: str, spec: Mapping[str, Any], params: Mapping[str, Any]
1518
+ ) -> None:
1519
+ item_tmpl = spec.get("item", {})
1520
+ item = {
1521
+ field: self._serializer.serialize(resolve_template(tmpl, params))
1522
+ for field, tmpl in item_tmpl.items()
1523
+ }
1524
+ self._add_key_attributes(spec["entity"], item, params)
1525
+
1526
+ request: Dict[str, Any] = {
1527
+ "TableName": self._physical_table(spec["tableName"]),
1528
+ "Item": item,
1529
+ }
1530
+ self._apply_condition(spec, request, params)
1531
+ self._call(command_id, self._client.put_item, request)
1532
+
1533
+ def _run_update_item(
1534
+ self, command_id: str, spec: Mapping[str, Any], params: Mapping[str, Any]
1535
+ ) -> None:
1536
+ key = {
1537
+ attr: self._serializer.serialize(resolve_template(tmpl, params))
1538
+ for attr, tmpl in spec["keyCondition"].items()
1539
+ }
1540
+ names: Dict[str, str] = {}
1541
+ values: Dict[str, Any] = {}
1542
+ sets: List[str] = []
1543
+ for i, (field, tmpl) in enumerate(spec.get("changes", {}).items()):
1544
+ n = f"#c{i}"
1545
+ v = f":c{i}"
1546
+ names[n] = field
1547
+ values[v] = self._serializer.serialize(resolve_template(tmpl, params))
1548
+ sets.append(f"{n} = {v}")
1549
+
1550
+ request: Dict[str, Any] = {
1551
+ "TableName": self._physical_table(spec["tableName"]),
1552
+ "Key": key,
1553
+ }
1554
+ if sets:
1555
+ request["UpdateExpression"] = "SET " + ", ".join(sets)
1556
+ request["ExpressionAttributeNames"] = names
1557
+ request["ExpressionAttributeValues"] = values
1558
+ self._apply_condition(spec, request, params)
1559
+ self._call(command_id, self._client.update_item, request)
1560
+
1561
+ def _run_delete_item(
1562
+ self, command_id: str, spec: Mapping[str, Any], params: Mapping[str, Any]
1563
+ ) -> None:
1564
+ key = {
1565
+ attr: self._serializer.serialize(resolve_template(tmpl, params))
1566
+ for attr, tmpl in spec["keyCondition"].items()
1567
+ }
1568
+ request: Dict[str, Any] = {
1569
+ "TableName": self._physical_table(spec["tableName"]),
1570
+ "Key": key,
1571
+ }
1572
+ self._apply_condition(spec, request, params)
1573
+ self._call(command_id, self._client.delete_item, request)
1574
+
1575
+ def _apply_condition(
1576
+ self,
1577
+ spec: Mapping[str, Any],
1578
+ request: Dict[str, Any],
1579
+ params: Mapping[str, Any],
1580
+ ) -> None:
1581
+ condition = spec.get("condition")
1582
+ if not condition:
1583
+ return
1584
+ names = request.setdefault("ExpressionAttributeNames", {})
1585
+ kind = condition["kind"]
1586
+ if kind == "notExists":
1587
+ names["#pk"] = "PK"
1588
+ request["ConditionExpression"] = "attribute_not_exists(#pk)"
1589
+ elif kind in ("attributeExists", "attributeNotExists"):
1590
+ # attribute_(not_)exists on any field (incl. PK/SK), issue #81. Routed
1591
+ # through a "#ce" placeholder so reserved words / key attributes are
1592
+ # always legal.
1593
+ fn = (
1594
+ "attribute_exists"
1595
+ if kind == "attributeExists"
1596
+ else "attribute_not_exists"
1597
+ )
1598
+ names["#ce"] = condition["field"]
1599
+ request["ConditionExpression"] = f"{fn}(#ce)"
1600
+ elif kind == "equals":
1601
+ values = request.setdefault("ExpressionAttributeValues", {})
1602
+ clauses = []
1603
+ for i, (field, tmpl) in enumerate(condition["fields"].items()):
1604
+ n = f"#e{i}"
1605
+ v = f":e{i}"
1606
+ names[n] = field
1607
+ # Resolve `{param}` templates against the caller params before
1608
+ # building the ConditionExpression value (issue #46: this was the
1609
+ # #44 latent bug — the raw template was serialized verbatim, so a
1610
+ # condition like `{version}` compared against the literal string
1611
+ # "{version}" rather than the bound value).
1612
+ resolved = resolve_template(tmpl, params)
1613
+ values[v] = self._serializer.serialize(resolved)
1614
+ clauses.append(f"{n} = {v}")
1615
+ request["ConditionExpression"] = " AND ".join(clauses)
1616
+
1617
+ def _add_key_attributes(
1618
+ self, entity_name: str, item: Dict[str, Any], params: Mapping[str, Any]
1619
+ ) -> None:
1620
+ entity_meta = self._entities.get(entity_name, {})
1621
+ plain = {
1622
+ field: self._deserializer.deserialize(av) for field, av in item.items()
1623
+ }
1624
+ key = entity_meta.get("key")
1625
+ if key:
1626
+ # pkTemplate is self-contained (segment key model #51); no prefix.
1627
+ pk = self._fill(key.get("pkTemplate") or "", plain)
1628
+ item["PK"] = self._serializer.serialize(pk)
1629
+ sk_tmpl = key.get("skTemplate")
1630
+ if sk_tmpl is not None:
1631
+ item["SK"] = self._serializer.serialize(self._fill(sk_tmpl, plain))
1632
+
1633
+ for gsi in entity_meta.get("gsis", []):
1634
+ index = gsi["indexName"]
1635
+ pk_tmpl = gsi.get("pkTemplate")
1636
+ sk_tmpl = gsi.get("skTemplate")
1637
+ if pk_tmpl is not None and self._fillable(pk_tmpl, plain):
1638
+ item[f"{index}PK"] = self._serializer.serialize(
1639
+ self._fill(pk_tmpl, plain)
1640
+ )
1641
+ if sk_tmpl is not None and self._fillable(sk_tmpl, plain):
1642
+ item[f"{index}SK"] = self._serializer.serialize(
1643
+ self._fill(sk_tmpl, plain)
1644
+ )
1645
+
1646
+ @staticmethod
1647
+ def _fillable(template: str, values: Mapping[str, Any]) -> bool:
1648
+ return all(
1649
+ m.group(0)[1:-1] in values for m in _PLACEHOLDER_RE.finditer(template)
1650
+ )
1651
+
1652
+ @staticmethod
1653
+ def _fill(template: str, values: Mapping[str, Any]) -> str:
1654
+ def repl(match: "re.Match[str]") -> str:
1655
+ name = match.group(0)[1:-1]
1656
+ return str(values.get(name, ""))
1657
+
1658
+ return _PLACEHOLDER_RE.sub(repl, template)
1659
+
1660
+ # ── helpers: boto3 plumbing ──────────────────────────────────────────────────
1661
+
1662
+ def _serialize_key(self, key: Mapping[str, Any]) -> Dict[str, Any]:
1663
+ return {k: self._serializer.serialize(v) for k, v in key.items()}
1664
+
1665
+ def _deserialize(self, item: Mapping[str, Any]) -> Dict[str, Any]:
1666
+ return {k: self._deserializer.deserialize(v) for k, v in item.items()}
1667
+
1668
+ def _call(self, operation_id: str, fn: Any, request: Dict[str, Any]) -> Any:
1669
+ try:
1670
+ return fn(**request)
1671
+ except _BOTO_ERRORS as exc: # type: ignore[misc]
1672
+ raise OperationExecutionError(
1673
+ f"{operation_id}: DynamoDB operation failed: {exc}", original=exc
1674
+ ) from exc