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,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
|