graphddb-runtime 0.2.0__tar.gz → 0.2.2__tar.gz
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-0.2.0 → graphddb_runtime-0.2.2}/PKG-INFO +1 -1
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/filters.py +5 -3
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/runtime.py +147 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/transactions.py +84 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime.egg-info/PKG-INFO +1 -1
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/pyproject.toml +1 -1
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_unit.py +187 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/README.md +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/__init__.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/async_runtime.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/batch.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/concurrency.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/cursor.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/errors.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/hydration.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/limits.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/per_key_cursor.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/relations.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime/templates.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime.egg-info/SOURCES.txt +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime.egg-info/requires.txt +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime.egg-info/top_level.txt +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/setup.cfg +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_concurrency.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_contract_runtime.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_command.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_compose.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_contract.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_edge_derive.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_edge_write.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_events.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_referential.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_relations.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_integration_unique.py +0 -0
- {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/tests/test_relations.py +0 -0
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Port of the TypeScript ``compileFilterExpression``
|
|
4
4
|
(``src/expression/filter-expression.ts``). The declarative tree carried in a
|
|
5
|
-
query spec's ``filter.declarative`` is JSON-safe
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
query spec's ``filter.declarative`` is JSON-safe, so only the operator/logical
|
|
6
|
+
forms are handled here. (A read-side ``cond`` escape hatch is not serialized into
|
|
7
|
+
``filter.declarative``; a *write*-side ``cond`` rides the separate ``raw``
|
|
8
|
+
condition spec — issue #114-B — rendered in ``runtime.py`` / ``transactions.py``,
|
|
9
|
+
not here.)
|
|
8
10
|
|
|
9
11
|
Names are ``#``-aliased columns (reused per distinct column); values are
|
|
10
12
|
``:``-aliased parameters — no literal interpolation. The result is returned in
|
|
@@ -64,6 +64,53 @@ def _normalize_template(template: Optional[str]) -> Optional[str]:
|
|
|
64
64
|
return _PLACEHOLDER_RE.sub("\x00", template)
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def _resolve_condition_leaf(value: Any, params: Mapping[str, Any]) -> Any:
|
|
68
|
+
"""Resolve one declarative-condition operand leaf (issue #114-A).
|
|
69
|
+
|
|
70
|
+
A ``{"$param": name}`` marker is replaced by the **typed** caller-param value
|
|
71
|
+
(so a numeric comparison stays numeric); a native literal is returned as-is.
|
|
72
|
+
"""
|
|
73
|
+
if isinstance(value, dict) and isinstance(value.get("$param"), str):
|
|
74
|
+
name = value["$param"]
|
|
75
|
+
if name not in params or params[name] is None:
|
|
76
|
+
raise KeyError(f"condition references unbound parameter '{name}'")
|
|
77
|
+
return params[name]
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_condition_tree(node: Any, params: Mapping[str, Any]) -> Any:
|
|
82
|
+
"""Resolve every ``{"$param"}`` marker in a serialized condition tree.
|
|
83
|
+
|
|
84
|
+
Walks the JSON-safe operator tree the TS planner emits (field operator
|
|
85
|
+
objects + ``and`` / ``or`` / ``not`` groups), substituting param markers with
|
|
86
|
+
typed caller-param values, and returns a tree shaped exactly like a
|
|
87
|
+
declarative ``filter`` so :func:`compile_filter` compiles it identically.
|
|
88
|
+
"""
|
|
89
|
+
out: Dict[str, Any] = {}
|
|
90
|
+
for key, value in node.items():
|
|
91
|
+
if key in ("and", "or"):
|
|
92
|
+
out[key] = [_resolve_condition_tree(s, params) for s in value]
|
|
93
|
+
elif key == "not":
|
|
94
|
+
out[key] = _resolve_condition_tree(value, params)
|
|
95
|
+
else:
|
|
96
|
+
ops: Dict[str, Any] = {}
|
|
97
|
+
for op, op_val in value.items():
|
|
98
|
+
if op == "between":
|
|
99
|
+
lo, hi = op_val
|
|
100
|
+
ops[op] = [
|
|
101
|
+
_resolve_condition_leaf(lo, params),
|
|
102
|
+
_resolve_condition_leaf(hi, params),
|
|
103
|
+
]
|
|
104
|
+
elif op == "in":
|
|
105
|
+
ops[op] = [_resolve_condition_leaf(v, params) for v in op_val]
|
|
106
|
+
elif op == "attributeExists":
|
|
107
|
+
ops[op] = op_val
|
|
108
|
+
else:
|
|
109
|
+
ops[op] = _resolve_condition_leaf(op_val, params)
|
|
110
|
+
out[key] = ops
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
67
114
|
def _present(records: Any) -> List[Dict[str, Any]]:
|
|
68
115
|
"""The present (non-``None``, dict) parent records from a result collection.
|
|
69
116
|
|
|
@@ -1602,6 +1649,17 @@ class GraphDDBRuntime:
|
|
|
1602
1649
|
values[v] = self._serializer.serialize(resolve_template(tmpl, params))
|
|
1603
1650
|
sets.append(f"{n} = {v}")
|
|
1604
1651
|
|
|
1652
|
+
# GSI key re-derivation (issue #115): an UpdateItem that changes a GSI's
|
|
1653
|
+
# composing field MUST re-derive its `<index>PK`/`<index>SK` or the index
|
|
1654
|
+
# silently rots — exactly as the TS `buildUpdateInput` does. The available
|
|
1655
|
+
# values are the caller `params` (key fields ∪ change fields); a GSI is
|
|
1656
|
+
# affected when at least one of its template placeholders is a changed
|
|
1657
|
+
# field. An affected GSI whose composing fields are not all available
|
|
1658
|
+
# throws (mirroring the base-table partial-key throw).
|
|
1659
|
+
self._append_gsi_rederive_sets(
|
|
1660
|
+
command_id, spec, params, names, values, sets
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1605
1663
|
request: Dict[str, Any] = {
|
|
1606
1664
|
"TableName": self._physical_table(spec["tableName"]),
|
|
1607
1665
|
"Key": key,
|
|
@@ -1613,6 +1671,66 @@ class GraphDDBRuntime:
|
|
|
1613
1671
|
self._apply_condition(spec, request, params)
|
|
1614
1672
|
self._call(command_id, self._client.update_item, request)
|
|
1615
1673
|
|
|
1674
|
+
def _append_gsi_rederive_sets(
|
|
1675
|
+
self,
|
|
1676
|
+
command_id: str,
|
|
1677
|
+
spec: Mapping[str, Any],
|
|
1678
|
+
params: Mapping[str, Any],
|
|
1679
|
+
names: Dict[str, str],
|
|
1680
|
+
values: Dict[str, Any],
|
|
1681
|
+
sets: List[str],
|
|
1682
|
+
) -> None:
|
|
1683
|
+
"""Re-derive every affected GSI key into the UpdateExpression (issue #115).
|
|
1684
|
+
|
|
1685
|
+
Mirrors the TS ``buildUpdateInput``: a GSI is *affected* when at least one
|
|
1686
|
+
of its ``inputFields`` is a changed field; an affected GSI re-derives its
|
|
1687
|
+
``<index>PK``/``<index>SK`` from the available caller params (key fields ∪
|
|
1688
|
+
change fields). An affected GSI whose ``inputFields`` are not all available
|
|
1689
|
+
throws — the SAME explicit error the TS path raises — rather than letting
|
|
1690
|
+
the index silently rot.
|
|
1691
|
+
"""
|
|
1692
|
+
entity_meta = self._entities.get(spec.get("entity"), {})
|
|
1693
|
+
gsis = entity_meta.get("gsis", [])
|
|
1694
|
+
if not gsis:
|
|
1695
|
+
return
|
|
1696
|
+
changed = set(spec.get("changes", {}).keys())
|
|
1697
|
+
if not changed:
|
|
1698
|
+
return
|
|
1699
|
+
# Available value map: caller params with a non-None value.
|
|
1700
|
+
available = {k: v for k, v in params.items() if v is not None}
|
|
1701
|
+
gsi_index = 0
|
|
1702
|
+
for gsi in gsis:
|
|
1703
|
+
input_fields = list(gsi.get("inputFields", []))
|
|
1704
|
+
if not any(f in changed for f in input_fields):
|
|
1705
|
+
continue # unaffected GSI — never touched (non-regression).
|
|
1706
|
+
missing = [f for f in input_fields if f not in available]
|
|
1707
|
+
if missing:
|
|
1708
|
+
changed_in_gsi = [f for f in input_fields if f in changed]
|
|
1709
|
+
raise GraphDDBError(
|
|
1710
|
+
f"{command_id}: updating "
|
|
1711
|
+
f"{', '.join(repr(f) for f in changed_in_gsi)} affects index "
|
|
1712
|
+
f"'{gsi['indexName']}' (also depends on "
|
|
1713
|
+
f"{', '.join(repr(f) for f in missing)}); provide "
|
|
1714
|
+
f"{'them' if len(missing) > 1 else 'it'}, or use "
|
|
1715
|
+
f"read-modify-write."
|
|
1716
|
+
)
|
|
1717
|
+
index = gsi["indexName"]
|
|
1718
|
+
pk_tmpl = gsi.get("pkTemplate")
|
|
1719
|
+
sk_tmpl = gsi.get("skTemplate")
|
|
1720
|
+
if pk_tmpl is not None:
|
|
1721
|
+
n = f"#gsi{gsi_index}pk"
|
|
1722
|
+
v = f":gsi{gsi_index}pk"
|
|
1723
|
+
names[n] = f"{index}PK"
|
|
1724
|
+
values[v] = self._serializer.serialize(self._fill(pk_tmpl, available))
|
|
1725
|
+
sets.append(f"{n} = {v}")
|
|
1726
|
+
if sk_tmpl is not None:
|
|
1727
|
+
n = f"#gsi{gsi_index}sk"
|
|
1728
|
+
v = f":gsi{gsi_index}sk"
|
|
1729
|
+
names[n] = f"{index}SK"
|
|
1730
|
+
values[v] = self._serializer.serialize(self._fill(sk_tmpl, available))
|
|
1731
|
+
sets.append(f"{n} = {v}")
|
|
1732
|
+
gsi_index += 1
|
|
1733
|
+
|
|
1616
1734
|
def _run_delete_item(
|
|
1617
1735
|
self, command_id: str, spec: Mapping[str, Any], params: Mapping[str, Any]
|
|
1618
1736
|
) -> None:
|
|
@@ -1668,6 +1786,35 @@ class GraphDDBRuntime:
|
|
|
1668
1786
|
values[v] = self._serializer.serialize(resolved)
|
|
1669
1787
|
clauses.append(f"{n} = {v}")
|
|
1670
1788
|
request["ConditionExpression"] = " AND ".join(clauses)
|
|
1789
|
+
elif kind == "expr":
|
|
1790
|
+
# The declarative operator tree (issue #114-A): resolve each
|
|
1791
|
+
# ``{"$param": name}`` marker to its typed caller-param value, then
|
|
1792
|
+
# compile the tree to a ConditionExpression with the SAME mechanics as
|
|
1793
|
+
# the read-side filter (``compile_filter``) so TS and Python emit an
|
|
1794
|
+
# identical expression / semantics. ``compile_filter`` allocates
|
|
1795
|
+
# ``#f`` / ``:vf`` aliases that never collide with the update ``#c`` /
|
|
1796
|
+
# ``:c`` namespace, so the compiled names/values merge cleanly.
|
|
1797
|
+
concrete = _resolve_condition_tree(condition["declarative"], params)
|
|
1798
|
+
compiled = compile_filter(concrete, self._serializer)
|
|
1799
|
+
if compiled is None:
|
|
1800
|
+
return
|
|
1801
|
+
names.update(compiled["ExpressionAttributeNames"])
|
|
1802
|
+
values = request.setdefault("ExpressionAttributeValues", {})
|
|
1803
|
+
values.update(compiled["ExpressionAttributeValues"])
|
|
1804
|
+
request["ConditionExpression"] = compiled["FilterExpression"]
|
|
1805
|
+
elif kind == "raw":
|
|
1806
|
+
# A raw ``cond`` write condition (issue #114-B): the expression and
|
|
1807
|
+
# the ``#cr_*`` name aliases are finished; bind each ``:crN`` value to
|
|
1808
|
+
# its literal, or — when it is a ``{"$param": name}`` marker — to the
|
|
1809
|
+
# typed caller-param value, then serialize. ``#cr_*`` / ``:cr*`` never
|
|
1810
|
+
# collide with the update namespace, so they merge cleanly. The
|
|
1811
|
+
# expression mirrors the TS ``serializeRawCondition`` output verbatim.
|
|
1812
|
+
names.update(condition["names"])
|
|
1813
|
+
values = request.setdefault("ExpressionAttributeValues", {})
|
|
1814
|
+
for alias, raw_val in condition["values"].items():
|
|
1815
|
+
resolved = _resolve_condition_leaf(raw_val, params)
|
|
1816
|
+
values[alias] = self._serializer.serialize(resolved)
|
|
1817
|
+
request["ConditionExpression"] = condition["expression"]
|
|
1671
1818
|
|
|
1672
1819
|
def _add_key_attributes(
|
|
1673
1820
|
self, entity_name: str, item: Dict[str, Any], params: Mapping[str, Any]
|
|
@@ -18,6 +18,8 @@ from __future__ import annotations
|
|
|
18
18
|
import re
|
|
19
19
|
from typing import Any, Dict, List, Mapping, Optional
|
|
20
20
|
|
|
21
|
+
from .filters import compile_filter
|
|
22
|
+
|
|
21
23
|
_PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
|
|
22
24
|
_WHOLE_PLACEHOLDER_RE = re.compile(r"^\{[^{}]+\}$")
|
|
23
25
|
|
|
@@ -64,6 +66,59 @@ def _resolve_value(
|
|
|
64
66
|
return _resolve_template(template, params, element)
|
|
65
67
|
|
|
66
68
|
|
|
69
|
+
def _resolve_condition_leaf(
|
|
70
|
+
value: Any,
|
|
71
|
+
params: Mapping[str, Any],
|
|
72
|
+
element: Optional[Mapping[str, Any]],
|
|
73
|
+
) -> Any:
|
|
74
|
+
"""Resolve one declarative-condition operand leaf in a transaction (#114-A).
|
|
75
|
+
|
|
76
|
+
A ``{"$param": name}`` marker binds to the typed param value, or — when the
|
|
77
|
+
name is ``item.<field>`` — to the current ``forEach`` element's field; a
|
|
78
|
+
native literal is returned as-is.
|
|
79
|
+
"""
|
|
80
|
+
if isinstance(value, dict) and isinstance(value.get("$param"), str):
|
|
81
|
+
return _resolve_value("{" + value["$param"] + "}", params, element)
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_condition_tree(
|
|
86
|
+
node: Any,
|
|
87
|
+
params: Mapping[str, Any],
|
|
88
|
+
element: Optional[Mapping[str, Any]],
|
|
89
|
+
) -> Any:
|
|
90
|
+
"""Resolve every ``{"$param"}`` marker in a serialized condition tree (#114-A).
|
|
91
|
+
|
|
92
|
+
Mirrors the runtime's resolver but element-aware, producing a tree shaped
|
|
93
|
+
like a declarative ``filter`` for :func:`compile_filter`.
|
|
94
|
+
"""
|
|
95
|
+
out: Dict[str, Any] = {}
|
|
96
|
+
for key, value in node.items():
|
|
97
|
+
if key in ("and", "or"):
|
|
98
|
+
out[key] = [_resolve_condition_tree(s, params, element) for s in value]
|
|
99
|
+
elif key == "not":
|
|
100
|
+
out[key] = _resolve_condition_tree(value, params, element)
|
|
101
|
+
else:
|
|
102
|
+
ops: Dict[str, Any] = {}
|
|
103
|
+
for op, op_val in value.items():
|
|
104
|
+
if op == "between":
|
|
105
|
+
lo, hi = op_val
|
|
106
|
+
ops[op] = [
|
|
107
|
+
_resolve_condition_leaf(lo, params, element),
|
|
108
|
+
_resolve_condition_leaf(hi, params, element),
|
|
109
|
+
]
|
|
110
|
+
elif op == "in":
|
|
111
|
+
ops[op] = [
|
|
112
|
+
_resolve_condition_leaf(v, params, element) for v in op_val
|
|
113
|
+
]
|
|
114
|
+
elif op == "attributeExists":
|
|
115
|
+
ops[op] = op_val
|
|
116
|
+
else:
|
|
117
|
+
ops[op] = _resolve_condition_leaf(op_val, params, element)
|
|
118
|
+
out[key] = ops
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
67
122
|
def _resolve_record(
|
|
68
123
|
record: Optional[Mapping[str, str]],
|
|
69
124
|
params: Mapping[str, Any],
|
|
@@ -318,6 +373,35 @@ class TransactionExpander:
|
|
|
318
373
|
target["ConditionExpression"] = f"{fn}(#ce)"
|
|
319
374
|
return
|
|
320
375
|
values = target.setdefault("ExpressionAttributeValues", {})
|
|
376
|
+
if kind == "expr":
|
|
377
|
+
# Declarative operator tree (issue #114-A): resolve each
|
|
378
|
+
# ``{"$param": name}`` marker against the tx params / forEach element
|
|
379
|
+
# (a ``{item.field}`` name binds from the element), then compile with
|
|
380
|
+
# the SAME ``compile_filter`` mechanics as the read-side filter. The
|
|
381
|
+
# compiled ``#f`` / ``:vf`` aliases never collide with the update
|
|
382
|
+
# ``#u`` namespace, so they merge cleanly.
|
|
383
|
+
concrete = _resolve_condition_tree(
|
|
384
|
+
condition["declarative"], params, element
|
|
385
|
+
)
|
|
386
|
+
compiled = compile_filter(concrete, self._serializer)
|
|
387
|
+
if compiled is None:
|
|
388
|
+
return
|
|
389
|
+
names.update(compiled["ExpressionAttributeNames"])
|
|
390
|
+
values.update(compiled["ExpressionAttributeValues"])
|
|
391
|
+
target["ConditionExpression"] = compiled["FilterExpression"]
|
|
392
|
+
return
|
|
393
|
+
if kind == "raw":
|
|
394
|
+
# A raw ``cond`` write condition (issue #114-B): the expression and
|
|
395
|
+
# ``#cr_*`` aliases are finished; bind each ``:crN`` value to its
|
|
396
|
+
# literal or — when a ``{"$param": name}`` marker — against the tx
|
|
397
|
+
# params / forEach element (a ``{item.field}`` name binds from the
|
|
398
|
+
# element). ``#cr_*`` / ``:cr*`` never collide with the update aliases.
|
|
399
|
+
names.update(condition["names"])
|
|
400
|
+
for alias, raw_val in condition["values"].items():
|
|
401
|
+
resolved = _resolve_condition_leaf(raw_val, params, element)
|
|
402
|
+
values[alias] = self._serializer.serialize(resolved)
|
|
403
|
+
target["ConditionExpression"] = condition["expression"]
|
|
404
|
+
return
|
|
321
405
|
clauses = []
|
|
322
406
|
for i, (field, tmpl) in enumerate(condition["fields"].items()):
|
|
323
407
|
n = f"#e{i}"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphddb-runtime"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -231,6 +231,54 @@ def test_update_item_builds_set_expression():
|
|
|
231
231
|
assert plain(req["ExpressionAttributeValues"]) == {":c0": "disabled"}
|
|
232
232
|
|
|
233
233
|
|
|
234
|
+
def test_update_item_rederives_affected_gsi_key():
|
|
235
|
+
# #115: setOrderStatus changes `status` (a GSI1 composing field). With
|
|
236
|
+
# `tenantId` also supplied, GSI1's composing fields {tenantId, status, orderId}
|
|
237
|
+
# are all available, so the update re-derives GSI1PK/GSI1SK in the SAME
|
|
238
|
+
# UpdateExpression — the index follows the row instead of rotting.
|
|
239
|
+
client = FakeClient()
|
|
240
|
+
rt = make_runtime(client)
|
|
241
|
+
rt.execute_command(
|
|
242
|
+
"setOrderStatus",
|
|
243
|
+
{"orderId": "o1", "tenantId": "t1", "status": "SHIPPED"},
|
|
244
|
+
)
|
|
245
|
+
method, req = client.calls[0]
|
|
246
|
+
assert method == "update_item"
|
|
247
|
+
names = req["ExpressionAttributeNames"]
|
|
248
|
+
values = plain(req["ExpressionAttributeValues"])
|
|
249
|
+
# The status SET plus the re-derived GSI key attributes.
|
|
250
|
+
assert "status" in names.values()
|
|
251
|
+
gsi_pk_alias = next(a for a, n in names.items() if n == "GSI1PK")
|
|
252
|
+
gsi_sk_alias = next(a for a, n in names.items() if n == "GSI1SK")
|
|
253
|
+
assert f"{gsi_pk_alias} = " in req["UpdateExpression"]
|
|
254
|
+
pk_val_alias = req["UpdateExpression"].split(f"{gsi_pk_alias} = ")[1].split()[0].rstrip(",")
|
|
255
|
+
sk_val_alias = req["UpdateExpression"].split(f"{gsi_sk_alias} = ")[1].split()[0].rstrip(",")
|
|
256
|
+
assert values[pk_val_alias] == "TENANT#t1#STATUS#SHIPPED"
|
|
257
|
+
assert values[sk_val_alias] == "ORDER#o1"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_update_item_throws_when_gsi_field_unavailable():
|
|
261
|
+
# #115: status alone affects GSI1 but tenantId is missing → explicit throw.
|
|
262
|
+
from graphddb_runtime.errors import GraphDDBError
|
|
263
|
+
|
|
264
|
+
client = FakeClient()
|
|
265
|
+
rt = make_runtime(client)
|
|
266
|
+
with pytest.raises(GraphDDBError, match=r"affects index 'GSI1'.*depends on 'tenantId'"):
|
|
267
|
+
rt.execute_command("setOrderStatusBad", {"orderId": "o1", "status": "SHIPPED"})
|
|
268
|
+
assert client.calls == [] # nothing written
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_update_item_does_not_touch_unaffected_gsi():
|
|
272
|
+
# #115 non-regression: disableUser changes `status` on a User whose GSI1 is
|
|
273
|
+
# keyed by `email` (status is NOT a composing field), so NO GSI re-derivation.
|
|
274
|
+
client = FakeClient()
|
|
275
|
+
rt = make_runtime(client)
|
|
276
|
+
rt.execute_command("disableUser", {"userId": "alice", "status": "disabled"})
|
|
277
|
+
_method, req = client.calls[0]
|
|
278
|
+
assert "GSI1PK" not in req["ExpressionAttributeNames"].values()
|
|
279
|
+
assert req["UpdateExpression"] == "SET #c0 = :c0"
|
|
280
|
+
|
|
281
|
+
|
|
234
282
|
def test_put_item_injects_key_attributes():
|
|
235
283
|
client = FakeClient()
|
|
236
284
|
rt = make_runtime(client)
|
|
@@ -342,6 +390,145 @@ def test_conditional_put_emits_attribute_not_exists_on_named_field():
|
|
|
342
390
|
assert req["ExpressionAttributeNames"]["#ce"] == "PK"
|
|
343
391
|
|
|
344
392
|
|
|
393
|
+
def test_conditional_expr_tree_renders_via_compile_filter():
|
|
394
|
+
"""Issue #114-A: a declarative operator tree (`{ kind: expr, declarative }`)
|
|
395
|
+
resolves its `{$param}` markers (typed) and compiles to a ConditionExpression
|
|
396
|
+
with the read-side filter mechanics (`#f`/`:vf` aliases).
|
|
397
|
+
"""
|
|
398
|
+
client = FakeClient()
|
|
399
|
+
rt = make_runtime(client)
|
|
400
|
+
rt._commands["addGroupMember"]["params"] = {
|
|
401
|
+
**rt._commands["addGroupMember"]["params"],
|
|
402
|
+
"role_0": {"type": "string", "required": True},
|
|
403
|
+
"role_1": {"type": "string", "required": True},
|
|
404
|
+
}
|
|
405
|
+
rt._commands["addGroupMember"]["condition"] = {
|
|
406
|
+
"kind": "expr",
|
|
407
|
+
"declarative": {
|
|
408
|
+
"and": [
|
|
409
|
+
{"role": {"in": [{"$param": "role_0"}, {"$param": "role_1"}]}},
|
|
410
|
+
{"role": {"beginsWith": {"$param": "role"}}},
|
|
411
|
+
]
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
rt.execute_command(
|
|
415
|
+
"addGroupMember",
|
|
416
|
+
{
|
|
417
|
+
"groupId": "eng",
|
|
418
|
+
"userId": "alice",
|
|
419
|
+
"role": "admin",
|
|
420
|
+
"role_0": "admin",
|
|
421
|
+
"role_1": "owner",
|
|
422
|
+
},
|
|
423
|
+
)
|
|
424
|
+
_method, req = client.calls[0]
|
|
425
|
+
assert req["ConditionExpression"] == (
|
|
426
|
+
"(#f0 IN (:vf0, :vf1) AND begins_with(#f0, :vf2))"
|
|
427
|
+
)
|
|
428
|
+
assert req["ExpressionAttributeNames"]["#f0"] == "role"
|
|
429
|
+
values = plain(req["ExpressionAttributeValues"])
|
|
430
|
+
assert values[":vf0"] == "admin"
|
|
431
|
+
assert values[":vf1"] == "owner"
|
|
432
|
+
assert values[":vf2"] == "admin"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def test_conditional_expr_numeric_comparison_keeps_type():
|
|
436
|
+
"""A numeric `{$param}` operand stays numeric (serialized as N), so `gt`
|
|
437
|
+
compares numerically rather than against a stringified value.
|
|
438
|
+
"""
|
|
439
|
+
client = FakeClient()
|
|
440
|
+
rt = make_runtime(client)
|
|
441
|
+
rt._commands["disableUser"]["params"] = {
|
|
442
|
+
**rt._commands["disableUser"]["params"],
|
|
443
|
+
"minVersion": {"type": "number", "required": True},
|
|
444
|
+
}
|
|
445
|
+
rt._commands["disableUser"]["condition"] = {
|
|
446
|
+
"kind": "expr",
|
|
447
|
+
"declarative": {"version": {"gt": {"$param": "minVersion"}}},
|
|
448
|
+
}
|
|
449
|
+
rt.execute_command(
|
|
450
|
+
"disableUser", {"userId": "alice", "status": "disabled", "minVersion": 3}
|
|
451
|
+
)
|
|
452
|
+
_method, req = client.calls[0]
|
|
453
|
+
assert req["ConditionExpression"] == "#f0 > :vf0"
|
|
454
|
+
assert req["ExpressionAttributeValues"][":vf0"] == {"N": "3"}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_conditional_expr_size_renders_size_function():
|
|
458
|
+
"""Audit fix (issue #114-A): the `size` operator must compile to
|
|
459
|
+
`size(#f) = :v`, not be silently dropped. The numeric length operand stays
|
|
460
|
+
N-typed.
|
|
461
|
+
"""
|
|
462
|
+
client = FakeClient()
|
|
463
|
+
rt = make_runtime(client)
|
|
464
|
+
rt._commands["addGroupMember"]["params"] = {
|
|
465
|
+
**rt._commands["addGroupMember"]["params"],
|
|
466
|
+
"role_size": {"type": "number", "required": True},
|
|
467
|
+
}
|
|
468
|
+
rt._commands["addGroupMember"]["condition"] = {
|
|
469
|
+
"kind": "expr",
|
|
470
|
+
"declarative": {"role": {"size": {"$param": "role_size"}}},
|
|
471
|
+
}
|
|
472
|
+
rt.execute_command(
|
|
473
|
+
"addGroupMember",
|
|
474
|
+
{"groupId": "eng", "userId": "alice", "role": "admin", "role_size": 5},
|
|
475
|
+
)
|
|
476
|
+
_method, req = client.calls[0]
|
|
477
|
+
assert req["ConditionExpression"] == "size(#f0) = :vf0"
|
|
478
|
+
assert req["ExpressionAttributeNames"]["#f0"] == "role"
|
|
479
|
+
assert req["ExpressionAttributeValues"][":vf0"] == {"N": "5"}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def test_conditional_raw_cond_literal_renders_verbatim():
|
|
483
|
+
"""Issue #114-B: a raw `cond` write condition (`{ kind: raw }`) attaches its
|
|
484
|
+
pre-compiled expression + `#cr_*` names verbatim, binding each `:crN` literal
|
|
485
|
+
value (here 'banned') exactly as the TS `serializeRawCondition` emitted it.
|
|
486
|
+
"""
|
|
487
|
+
client = FakeClient()
|
|
488
|
+
rt = make_runtime(client)
|
|
489
|
+
rt._commands["addGroupMember"]["condition"] = {
|
|
490
|
+
"kind": "raw",
|
|
491
|
+
"expression": "#cr_role <> :cr0 AND attribute_exists(#cr_role)",
|
|
492
|
+
"names": {"#cr_role": "role"},
|
|
493
|
+
"values": {":cr0": "banned"},
|
|
494
|
+
}
|
|
495
|
+
rt.execute_command(
|
|
496
|
+
"addGroupMember", {"groupId": "eng", "userId": "alice", "role": "admin"}
|
|
497
|
+
)
|
|
498
|
+
_method, req = client.calls[0]
|
|
499
|
+
assert req["ConditionExpression"] == (
|
|
500
|
+
"#cr_role <> :cr0 AND attribute_exists(#cr_role)"
|
|
501
|
+
)
|
|
502
|
+
assert req["ExpressionAttributeNames"]["#cr_role"] == "role"
|
|
503
|
+
assert plain(req["ExpressionAttributeValues"])[":cr0"] == "banned"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def test_conditional_raw_cond_binds_param_marker():
|
|
507
|
+
"""Issue #114-B: a `{ $param }` value marker in a raw `cond` condition binds
|
|
508
|
+
from the typed caller param (here `cond_p0`), so TS and Python compare against
|
|
509
|
+
the same bound value.
|
|
510
|
+
"""
|
|
511
|
+
client = FakeClient()
|
|
512
|
+
rt = make_runtime(client)
|
|
513
|
+
rt._commands["addGroupMember"]["params"] = {
|
|
514
|
+
**rt._commands["addGroupMember"]["params"],
|
|
515
|
+
"cond_p0": {"type": "string", "required": True},
|
|
516
|
+
}
|
|
517
|
+
rt._commands["addGroupMember"]["condition"] = {
|
|
518
|
+
"kind": "raw",
|
|
519
|
+
"expression": "#cr_role = :cr0",
|
|
520
|
+
"names": {"#cr_role": "role"},
|
|
521
|
+
"values": {":cr0": {"$param": "cond_p0"}},
|
|
522
|
+
}
|
|
523
|
+
rt.execute_command(
|
|
524
|
+
"addGroupMember",
|
|
525
|
+
{"groupId": "eng", "userId": "alice", "role": "admin", "cond_p0": "admin"},
|
|
526
|
+
)
|
|
527
|
+
_method, req = client.calls[0]
|
|
528
|
+
assert req["ConditionExpression"] == "#cr_role = :cr0"
|
|
529
|
+
assert plain(req["ExpressionAttributeValues"])[":cr0"] == "admin"
|
|
530
|
+
|
|
531
|
+
|
|
345
532
|
# ── declarative transactions (issue #46) ────────────────────────────────────
|
|
346
533
|
|
|
347
534
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphddb_runtime-0.2.0 → graphddb_runtime-0.2.2}/graphddb_runtime.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|