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,131 @@
1
+ """Parameter validation and ``{param}`` template resolution (issue #44).
2
+
3
+ Templates follow the #42 ``OperationSpec`` form: a value such as
4
+ ``EMAIL#{email}`` or ``USER#{userId}`` contains zero or more ``{name}``
5
+ placeholders bound from caller params. ``{result.field}`` placeholders are
6
+ relation-chain bindings and never appear in the single-operation core; a
7
+ template carrying one is rejected as out of scope by the executor before this
8
+ module is reached.
9
+
10
+ A value with no ``{...}`` is a literal (e.g. the discriminator ``PROFILE`` or a
11
+ serialized ``Date`` like ``1970-01-01T00:00:00.000Z``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import Any, Mapping
18
+
19
+ from .errors import ParameterValidationError
20
+
21
+ # A placeholder is `{` + name (no dot) + `}`. `{result.*}` is matched but flagged
22
+ # separately by the executor so it can raise the multi-op error.
23
+ _PLACEHOLDER = re.compile(r"\{([^{}]+)\}")
24
+
25
+
26
+ def validate_params(
27
+ params: Mapping[str, Any],
28
+ param_specs: Mapping[str, Mapping[str, Any]],
29
+ *,
30
+ operation_id: str,
31
+ ) -> None:
32
+ """Validate caller params against the spec's ``params`` declaration.
33
+
34
+ Raises :class:`ParameterValidationError` (before any DynamoDB call) on a
35
+ missing required param, an unknown param, a wrong scalar type, or a
36
+ ``literal`` value outside the allowed set.
37
+ """
38
+ for name, spec in param_specs.items():
39
+ required = bool(spec.get("required", False))
40
+ present = name in params and params[name] is not None
41
+ if required and not present:
42
+ raise ParameterValidationError(
43
+ f"{operation_id}: missing required parameter '{name}'"
44
+ )
45
+ if not present:
46
+ continue
47
+ _check_value(operation_id, name, spec, params[name])
48
+
49
+ for name in params:
50
+ if name not in param_specs:
51
+ raise ParameterValidationError(
52
+ f"{operation_id}: unknown parameter '{name}'"
53
+ )
54
+
55
+
56
+ def _check_value(
57
+ operation_id: str, name: str, spec: Mapping[str, Any], value: Any
58
+ ) -> None:
59
+ kind = spec.get("type")
60
+ if kind == "number":
61
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
62
+ raise ParameterValidationError(
63
+ f"{operation_id}: parameter '{name}' must be a number"
64
+ )
65
+ elif kind == "boolean":
66
+ if not isinstance(value, bool):
67
+ raise ParameterValidationError(
68
+ f"{operation_id}: parameter '{name}' must be a boolean"
69
+ )
70
+ elif kind == "literal":
71
+ literals = spec.get("literals") or []
72
+ if value not in literals:
73
+ allowed = ", ".join(repr(lit) for lit in literals)
74
+ raise ParameterValidationError(
75
+ f"{operation_id}: parameter '{name}' must be one of [{allowed}], got {value!r}"
76
+ )
77
+ elif kind == "array":
78
+ # An array param (transaction forEach source): a list whose elements each
79
+ # validate against the element field specs.
80
+ if not isinstance(value, list):
81
+ raise ParameterValidationError(
82
+ f"{operation_id}: parameter '{name}' must be an array"
83
+ )
84
+ element_specs = spec.get("element") or {}
85
+ for index, element in enumerate(value):
86
+ if not isinstance(element, dict):
87
+ raise ParameterValidationError(
88
+ f"{operation_id}: parameter '{name}'[{index}] must be an object"
89
+ )
90
+ for field, field_spec in element_specs.items():
91
+ present = field in element and element[field] is not None
92
+ if field_spec.get("required", False) and not present:
93
+ raise ParameterValidationError(
94
+ f"{operation_id}: parameter '{name}'[{index}] missing "
95
+ f"required field '{field}'"
96
+ )
97
+ if present:
98
+ _check_value(
99
+ operation_id, f"{name}[{index}].{field}", field_spec,
100
+ element[field],
101
+ )
102
+ else: # 'string' and any other scalar default to string.
103
+ if not isinstance(value, str):
104
+ raise ParameterValidationError(
105
+ f"{operation_id}: parameter '{name}' must be a string"
106
+ )
107
+
108
+
109
+ def has_result_placeholder(template: str) -> bool:
110
+ """True if the template references a prior result (``{result.*}``)."""
111
+ return any(m.group(1).startswith("result.") for m in _PLACEHOLDER.finditer(template))
112
+
113
+
114
+ def resolve_template(template: str, params: Mapping[str, Any]) -> str:
115
+ """Substitute ``{param}`` placeholders in ``template`` with param values.
116
+
117
+ The whole value is returned as a string (key/sort-key values are strings in
118
+ the single-table layout). Missing params should have been caught by
119
+ :func:`validate_params`; a stray placeholder here raises
120
+ :class:`ParameterValidationError`.
121
+ """
122
+
123
+ def repl(match: "re.Match[str]") -> str:
124
+ name = match.group(1)
125
+ if name not in params or params[name] is None:
126
+ raise ParameterValidationError(
127
+ f"template '{template}' references unbound parameter '{name}'"
128
+ )
129
+ return str(params[name])
130
+
131
+ return _PLACEHOLDER.sub(repl, template)
@@ -0,0 +1,440 @@
1
+ """Declarative transaction expansion + execution (issue #46, Phase 4).
2
+
3
+ Interprets a serializable ``TransactionSpec`` (from ``operations.json``): expands
4
+ each templated item with the caller params (and, for ``forEach`` items, each
5
+ element of the named array param), derives PK/SK/GSI key attributes from the
6
+ manifest exactly as the single-write path does, builds a boto3
7
+ ``transact_write_items`` request (≤25), and executes it. A failed transaction
8
+ (``TransactionCanceledException``) is wrapped in ``OperationExecutionError``,
9
+ matching the single-write error policy.
10
+
11
+ Template forms mirror the planner and the TS executor: ``{param}`` resolves from
12
+ the caller params; ``{item.<field>}`` resolves from the current ``forEach``
13
+ element. A leaf with no placeholder is a literal.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Any, Dict, List, Mapping, Optional
20
+
21
+ _PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
22
+ _WHOLE_PLACEHOLDER_RE = re.compile(r"^\{[^{}]+\}$")
23
+
24
+ MAX_TRANSACT_ITEMS = 25
25
+
26
+
27
+ def _resolve_template(
28
+ template: str,
29
+ params: Mapping[str, Any],
30
+ element: Optional[Mapping[str, Any]],
31
+ ) -> str:
32
+ """Resolve ``{param}`` / ``{item.field}`` placeholders to a string."""
33
+
34
+ def repl(match: "re.Match[str]") -> str:
35
+ name = match.group(0)[1:-1]
36
+ if name.startswith("item."):
37
+ field = name[len("item.") :]
38
+ if element is None or field not in element:
39
+ raise KeyError(
40
+ f"element field '{field}' not bound for template '{template}'"
41
+ )
42
+ return str(element[field])
43
+ if name not in params or params[name] is None:
44
+ raise KeyError(f"template '{template}' references unbound param '{name}'")
45
+ return str(params[name])
46
+
47
+ return _PLACEHOLDER_RE.sub(repl, template)
48
+
49
+
50
+ def _resolve_value(
51
+ template: str,
52
+ params: Mapping[str, Any],
53
+ element: Optional[Mapping[str, Any]],
54
+ ) -> Any:
55
+ """Resolve a value template, keeping the typed value for a whole placeholder."""
56
+ if _WHOLE_PLACEHOLDER_RE.match(template):
57
+ name = template[1:-1]
58
+ if name.startswith("item."):
59
+ field = name[len("item.") :]
60
+ if element is not None and field in element:
61
+ return element[field]
62
+ elif name in params and params[name] is not None:
63
+ return params[name]
64
+ return _resolve_template(template, params, element)
65
+
66
+
67
+ def _resolve_record(
68
+ record: Optional[Mapping[str, str]],
69
+ params: Mapping[str, Any],
70
+ element: Optional[Mapping[str, Any]],
71
+ ) -> Dict[str, Any]:
72
+ return {
73
+ field: _resolve_value(tmpl, params, element)
74
+ for field, tmpl in (record or {}).items()
75
+ }
76
+
77
+
78
+ def _fill_key(template: str, item: Mapping[str, Any]) -> str:
79
+ def repl(match: "re.Match[str]") -> str:
80
+ name = match.group(0)[1:-1]
81
+ return str(item.get(name, ""))
82
+
83
+ return _PLACEHOLDER_RE.sub(repl, template)
84
+
85
+
86
+ def _key_fillable(template: str, item: Mapping[str, Any]) -> bool:
87
+ return all(m.group(0)[1:-1] in item for m in _PLACEHOLDER_RE.finditer(template))
88
+
89
+
90
+ def _when_holds(
91
+ when: Mapping[str, Any],
92
+ params: Mapping[str, Any],
93
+ element: Optional[Mapping[str, Any]],
94
+ ) -> bool:
95
+ left = _resolve_template(when["left"], params, element)
96
+ right = _resolve_template(when["right"], params, element)
97
+ return left == right if when["op"] == "eq" else left != right
98
+
99
+
100
+ def _attr_str(value: Any) -> str:
101
+ """The string form of a serialized DynamoDB attribute value (PK/SK are strings)."""
102
+ if isinstance(value, Mapping):
103
+ # Serialized boto3 attribute value, e.g. {"S": "UNIQUE#..."}; take the inner.
104
+ return str(next(iter(value.values()))) if value else ""
105
+ return str(value)
106
+
107
+
108
+ def _item_kind(item: Mapping[str, Any]) -> str:
109
+ for kind in ("Put", "Update", "Delete", "ConditionCheck"):
110
+ if kind in item:
111
+ return kind
112
+ raise ValueError(f"transact item has no recognized op: {sorted(item)}")
113
+
114
+
115
+ def _item_key_signature(item: Mapping[str, Any]) -> str:
116
+ """The ``(table, PK, SK)`` signature of a built transact item, for the collapse.
117
+
118
+ A ``Put`` carries its key in ``Item.PK`` / ``Item.SK``; the others in
119
+ ``Key.PK`` / ``Key.SK``. Values are serialized boto3 attribute values.
120
+ """
121
+ kind = _item_kind(item)
122
+ body = item[kind]
123
+ rec = body.get("Item") if kind == "Put" else body.get("Key")
124
+ rec = rec or {}
125
+ table = body.get("TableName", "")
126
+ return f"{table}#{_attr_str(rec.get('PK'))}#{_attr_str(rec.get('SK'))}"
127
+
128
+
129
+ def _merge_add_updates(updates: List[Dict[str, Any]]) -> Dict[str, Any]:
130
+ """Merge several same-key atomic-``ADD`` ``Update`` items into ONE, summing the
131
+ deltas per target attribute.
132
+
133
+ An edge move with ``old == new`` derives ``counter -= 1`` AND ``counter += 1`` on
134
+ the SAME row (#92): two operations on one item are rejected, so they fold into a
135
+ single net-zero ADD (a legal single write). Each item is an ADD built like the TS
136
+ ``renderDerivedUpdate`` (``ADD #aN :aN``); a numeric value (serialized ``{"N": …}``)
137
+ is summed, mirroring the TS ``mergeAddUpdates`` so the same SSoT yields one identical
138
+ ADD.
139
+ """
140
+ first = updates[0]["Update"]
141
+ deltas: Dict[str, int] = {}
142
+ for it in updates:
143
+ upd = it["Update"]
144
+ names = upd.get("ExpressionAttributeNames", {})
145
+ values = upd.get("ExpressionAttributeValues", {})
146
+ for name_ph, attr in names.items():
147
+ value_ph = f":{name_ph[1:]}"
148
+ serialized = values.get(value_ph, {})
149
+ # A serialized DynamoDB number attribute, e.g. {"N": "1"}.
150
+ raw = serialized.get("N") if isinstance(serialized, Mapping) else serialized
151
+ deltas[attr] = deltas.get(attr, 0) + int(raw)
152
+ names_out: Dict[str, str] = {}
153
+ values_out: Dict[str, Any] = {}
154
+ clauses: List[str] = []
155
+ for i, (attr, delta) in enumerate(deltas.items()):
156
+ names_out[f"#a{i}"] = attr
157
+ values_out[f":a{i}"] = {"N": str(delta)}
158
+ clauses.append(f"#a{i} :a{i}")
159
+ return {
160
+ "Update": {
161
+ "TableName": first["TableName"],
162
+ "Key": first["Key"],
163
+ "UpdateExpression": "ADD " + ", ".join(clauses),
164
+ "ExpressionAttributeNames": names_out,
165
+ "ExpressionAttributeValues": values_out,
166
+ }
167
+ }
168
+
169
+
170
+ def _collapse_same_key_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
171
+ """Collapse items that target the SAME physical row (``(table, PK, SK)``).
172
+
173
+ The op-aware rule the TS in-process runtime (``collapseSameKeyItems``) applies
174
+ identically (issues #85, #86, #92). A ``TransactWriteItems`` may not touch one key
175
+ twice, so a same-key collision is always one of these derivation shapes:
176
+
177
+ - a **``Delete(old) + Put(new)`` swap pair** that resolved to the same key — a
178
+ uniqueness guard whose unique field did NOT change (#86), or an edge move with
179
+ ``old == new`` (#92): a **net no-op**, emit NEITHER (keeping the ``Put`` would
180
+ fail its ``attribute_not_exists`` against the already-claimed row; keeping the
181
+ ``Delete`` would drop a still-needed guard / edge — both wrong);
182
+ - **multiple ``Put``s (or multiple ``Delete``s) of the same modeled row** (one write
183
+ op, no swap — the self-row edge de-dup, #85): a derived edge Put targets the same row
184
+ a base Put writes (create), or a derived edge Delete targets the same row a base
185
+ Delete removes (remove): keep the FIRST, drop the rest;
186
+ - **multiple ``Update`` (ADD) counters on the same row** — an edge move with
187
+ ``old == new`` derives two ADDs on one counter (#92): MERGE them into one ADD with
188
+ the deltas summed per attribute (net ``0`` for the symmetric ±1).
189
+
190
+ A **``Put`` + ``Update`` collision** — a base entity write and a derived counter ADD
191
+ on the SAME row (issue #93) — is NOT a safe collapse shape: it is REJECTED loudly. The
192
+ build-time guard catches the statically determinable self-target, but an ALIASED
193
+ self-target (a counter keyed off a differently-named input the caller passes equal to
194
+ the write's key) only coincides at runtime and evades the static guard and the
195
+ distinct-template backstop. Folding it into the self-row Put de-dup would silently drop
196
+ the ADD — the exact silent-drop #93 forbids — so this and any other unhandled same-key
197
+ op combination raise rather than collapse.
198
+
199
+ A genuine swap (``old != new``) resolves to two distinct keys, so both survive.
200
+ First-seen order is preserved (a merged ADD takes the position of the FIRST update
201
+ in its bucket), so the output is identical to the TS runtimes' for the same SSoT.
202
+ """
203
+ groups: Dict[str, List[Dict[str, Any]]] = {}
204
+ for item in items:
205
+ sig = _item_key_signature(item)
206
+ groups.setdefault(sig, []).append(item)
207
+ out: List[Dict[str, Any]] = []
208
+ for item in items:
209
+ sig = _item_key_signature(item)
210
+ bucket = groups[sig]
211
+ if len(bucket) == 1:
212
+ out.append(item)
213
+ continue
214
+ kinds = {_item_kind(b) for b in bucket}
215
+ if "Delete" in kinds and "Put" in kinds:
216
+ # Swap pair resolved to one key → net no-op: drop EVERY member at this key.
217
+ continue
218
+ if "Put" in kinds and "Update" in kinds:
219
+ # Base entity Put + derived counter Update(ADD) on the SAME row (issue #93,
220
+ # the aliased self-target that evades the build-time guard). A Put+Update on
221
+ # one item is unsupported (DynamoDB rejects touching one key twice) and
222
+ # collapsing it would silently drop the increment — FAIL LOUDLY instead.
223
+ raise ValueError(
224
+ "a derived counter (`increment`) resolves to the SAME item as the "
225
+ "entity write at runtime (the call-time key values made an aliased "
226
+ "target coincide with the written row). A Put+Update on one transaction "
227
+ "item is unsupported — DynamoDB rejects touching one key twice, and "
228
+ "collapsing it would silently drop the increment. A `w.increment(...)` "
229
+ "must target a DIFFERENT row (e.g. a parent aggregate's counter); to "
230
+ "maintain a counter ON the written entity, write the field as part of "
231
+ "the entity itself, not via a derived counter onto its own row."
232
+ )
233
+ if item is not bucket[0]:
234
+ # Non-first members are folded into / dropped by the first member's handling.
235
+ continue
236
+ if kinds == {"Update"}:
237
+ # Same-key counter ADDs (an edge move with old==new) → one merged ADD.
238
+ out.append(_merge_add_updates(bucket))
239
+ elif kinds in ({"Put"}, {"Delete"}):
240
+ # Self-row edge de-dup (#85): a derived edge Put targets the same row a base
241
+ # Put writes (create), or a derived edge Delete targets the same row a base
242
+ # Delete removes (remove). The op is idempotent on one key — keep the FIRST.
243
+ out.append(item)
244
+ else:
245
+ # No other same-key op combination is a known-safe derivation shape. Reject
246
+ # loudly rather than silently collapse / drop an item.
247
+ raise ValueError(
248
+ f"{len(bucket)} transaction items resolve to the same physical row with "
249
+ f"an unsupported op combination ({'+'.join(sorted(kinds))}). A "
250
+ "TransactWriteItems may not touch one key twice; this collision is not a "
251
+ "known collapse shape (self-row Put/Delete de-dup, swap no-op, or "
252
+ "symmetric counter ADDs), so it is rejected rather than silently collapsed."
253
+ )
254
+ return out
255
+
256
+
257
+ class TransactionExpander:
258
+ """Expands a transaction spec into serialized boto3 TransactItems.
259
+
260
+ Bound to a runtime so it can reuse the runtime's serializer, manifest
261
+ entities, and physical-table mapping.
262
+ """
263
+
264
+ def __init__(self, runtime: Any) -> None:
265
+ self._rt = runtime
266
+ self._serializer = runtime._serializer
267
+ self._entities = runtime._entities
268
+
269
+ def _entity(self, name: str) -> Mapping[str, Any]:
270
+ entity = self._entities.get(name)
271
+ if entity is None:
272
+ raise KeyError(f"unknown entity '{name}' in manifest")
273
+ return entity
274
+
275
+ def _add_key_attributes(self, entity_name: str, plain: Dict[str, Any]) -> None:
276
+ entity = self._entity(entity_name)
277
+ key = entity.get("key")
278
+ if key:
279
+ # pkTemplate is self-contained (segment key model #51); no prefix.
280
+ plain["PK"] = _fill_key(key.get("pkTemplate") or "", plain)
281
+ sk_tmpl = key.get("skTemplate")
282
+ if sk_tmpl is not None:
283
+ plain["SK"] = _fill_key(sk_tmpl, plain)
284
+ for gsi in entity.get("gsis", []):
285
+ index = gsi["indexName"]
286
+ pk_tmpl = gsi.get("pkTemplate")
287
+ sk_tmpl = gsi.get("skTemplate")
288
+ if pk_tmpl is not None and _key_fillable(pk_tmpl, plain):
289
+ plain[f"{index}PK"] = _fill_key(pk_tmpl, plain)
290
+ if sk_tmpl is not None and _key_fillable(sk_tmpl, plain):
291
+ plain[f"{index}SK"] = _fill_key(sk_tmpl, plain)
292
+
293
+ def _apply_condition(
294
+ self,
295
+ condition: Optional[Mapping[str, Any]],
296
+ target: Dict[str, Any],
297
+ params: Mapping[str, Any],
298
+ element: Optional[Mapping[str, Any]],
299
+ ) -> None:
300
+ if not condition:
301
+ return
302
+ names = target.setdefault("ExpressionAttributeNames", {})
303
+ kind = condition["kind"]
304
+ if kind == "notExists":
305
+ names["#pk"] = "PK"
306
+ target["ConditionExpression"] = "attribute_not_exists(#pk)"
307
+ return
308
+ if kind in ("attributeExists", "attributeNotExists"):
309
+ # attribute_(not_)exists on any field (incl. PK/SK), issue #81. Routed
310
+ # through a "#ce" placeholder so reserved words / key attributes are
311
+ # always legal.
312
+ fn = (
313
+ "attribute_exists"
314
+ if kind == "attributeExists"
315
+ else "attribute_not_exists"
316
+ )
317
+ names["#ce"] = condition["field"]
318
+ target["ConditionExpression"] = f"{fn}(#ce)"
319
+ return
320
+ values = target.setdefault("ExpressionAttributeValues", {})
321
+ clauses = []
322
+ for i, (field, tmpl) in enumerate(condition["fields"].items()):
323
+ n = f"#e{i}"
324
+ v = f":e{i}"
325
+ names[n] = field
326
+ values[v] = self._serializer.serialize(
327
+ _resolve_value(tmpl, params, element)
328
+ )
329
+ clauses.append(f"{n} = {v}")
330
+ target["ConditionExpression"] = " AND ".join(clauses)
331
+
332
+ def _build_item(
333
+ self,
334
+ spec: Mapping[str, Any],
335
+ params: Mapping[str, Any],
336
+ element: Optional[Mapping[str, Any]],
337
+ ) -> Dict[str, Any]:
338
+ entity_name = spec["entity"]
339
+ table = self._rt._physical_table(spec["tableName"])
340
+ item_type = spec["type"]
341
+
342
+ if item_type == "Put":
343
+ plain = _resolve_record(spec.get("item"), params, element)
344
+ # A raw marker-row write (issue #86 uniqueness guard) carries its PK/SK
345
+ # LITERALLY in the item — there is no manifest entity, so do NOT derive
346
+ # key attributes from metadata; write the resolved record verbatim. A
347
+ # modeled Put still derives PK/SK/GSI from the entity exactly as before.
348
+ if not spec.get("literalKey"):
349
+ self._add_key_attributes(entity_name, plain)
350
+ serialized = {
351
+ k: self._serializer.serialize(v) for k, v in plain.items()
352
+ }
353
+ put: Dict[str, Any] = {"TableName": table, "Item": serialized}
354
+ self._apply_condition(spec.get("condition"), put, params, element)
355
+ return {"Put": put}
356
+
357
+ key_plain = _resolve_record(spec.get("keyCondition"), params, element)
358
+ key = {k: self._serializer.serialize(v) for k, v in key_plain.items()}
359
+
360
+ if item_type == "Delete":
361
+ delete: Dict[str, Any] = {"TableName": table, "Key": key}
362
+ self._apply_condition(spec.get("condition"), delete, params, element)
363
+ return {"Delete": delete}
364
+
365
+ if item_type == "ConditionCheck":
366
+ # A read-only assertion (issue #81): no Item / changes, just a keyed row
367
+ # plus a mandatory ConditionExpression whose failure cancels the whole
368
+ # transaction.
369
+ condition = spec.get("condition")
370
+ if not condition:
371
+ raise ValueError(
372
+ f"ConditionCheck on '{entity_name}' has no condition; a "
373
+ f"ConditionCheck must carry the assertion it makes."
374
+ )
375
+ check: Dict[str, Any] = {"TableName": table, "Key": key}
376
+ self._apply_condition(condition, check, params, element)
377
+ return {"ConditionCheck": check}
378
+
379
+ # Update. Two distinct DynamoDB actions, both supported (issue #85):
380
+ # - `changes` → `SET #f = :v` (overwrite a named field);
381
+ # - `add` → `ADD #f :delta` (atomic numeric increment, concurrency-safe;
382
+ # a derived counter such as `User.postCount += 1` MUST be an ADD so a
383
+ # concurrent increment is never clobbered — exactly the TS runtime's
384
+ # `buildAddUpdateInput`).
385
+ changes = _resolve_record(spec.get("changes"), params, element)
386
+ adds = _resolve_record(spec.get("add"), params, element)
387
+ names: Dict[str, str] = {}
388
+ values: Dict[str, Any] = {}
389
+ sets: List[str] = []
390
+ add_clauses: List[str] = []
391
+ for i, (field, value) in enumerate(changes.items()):
392
+ n = f"#c{i}"
393
+ v = f":c{i}"
394
+ names[n] = field
395
+ values[v] = self._serializer.serialize(value)
396
+ sets.append(f"{n} = {v}")
397
+ for i, (field, value) in enumerate(adds.items()):
398
+ n = f"#a{i}"
399
+ v = f":a{i}"
400
+ names[n] = field
401
+ # An ADD delta is numeric; serialize it as a number even though the
402
+ # template rendered it to a string (the literal `"1"` / `"-1"`).
403
+ values[v] = self._serializer.serialize(int(value) if isinstance(value, str) else value)
404
+ add_clauses.append(f"{n} {v}")
405
+ update: Dict[str, Any] = {"TableName": table, "Key": key}
406
+ expr_parts: List[str] = []
407
+ if sets:
408
+ expr_parts.append("SET " + ", ".join(sets))
409
+ if add_clauses:
410
+ expr_parts.append("ADD " + ", ".join(add_clauses))
411
+ if expr_parts:
412
+ update["UpdateExpression"] = " ".join(expr_parts)
413
+ update["ExpressionAttributeNames"] = names
414
+ update["ExpressionAttributeValues"] = values
415
+ self._apply_condition(spec.get("condition"), update, params, element)
416
+ return {"Update": update}
417
+
418
+ def expand(
419
+ self, spec: Mapping[str, Any], params: Mapping[str, Any]
420
+ ) -> List[Dict[str, Any]]:
421
+ expanded: List[Dict[str, Any]] = []
422
+ for item in spec.get("items", []):
423
+ for_each = item.get("forEach")
424
+ when = item.get("when")
425
+ if for_each:
426
+ source = params.get(for_each["source"])
427
+ if not isinstance(source, list):
428
+ raise TypeError(
429
+ f"forEach source '{for_each['source']}' must be an array "
430
+ f"param; got {type(source).__name__}"
431
+ )
432
+ for element in source:
433
+ if when and not _when_holds(when, params, element):
434
+ continue
435
+ expanded.append(self._build_item(item, params, element))
436
+ else:
437
+ if when and not _when_holds(when, params, None):
438
+ continue
439
+ expanded.append(self._build_item(item, params, None))
440
+ return _collapse_same_key_items(expanded)