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