graphddb-runtime 0.7.10__tar.gz → 0.8.1__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.7.10 → graphddb_runtime-0.8.1}/PKG-INFO +1 -1
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/runtime.py +44 -12
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime.egg-info/PKG-INFO +1 -1
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/pyproject.toml +1 -1
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_concurrency.py +7 -7
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_description.py +2 -2
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_description_jsdoc.py +1 -1
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration.py +29 -29
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration_edge_write.py +2 -2
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration_middleware.py +11 -11
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration_relations.py +9 -9
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_middleware.py +44 -44
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_relations.py +9 -9
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_unit.py +46 -46
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/README.md +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/__init__.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/async_runtime.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/batch.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/concurrency.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/cursor.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/errors.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/filters.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/hydration.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/limits.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/middleware.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/per_key_cursor.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/relations.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/templates.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime/transactions.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime.egg-info/SOURCES.txt +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime.egg-info/requires.txt +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/graphddb_runtime.egg-info/top_level.txt +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/setup.cfg +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_contract_runtime.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_crosslang_python_cells.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_description_followups.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_guard_eval.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration_binding_contract.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration_command.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration_compose.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration_contract.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration_edge_derive.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration_events.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration_maintain.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration_referential.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_integration_unique.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_maintain.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_spec_version_guard.py +0 -0
- {graphddb_runtime-0.7.10 → graphddb_runtime-0.8.1}/tests/test_ttl.py +0 -0
|
@@ -268,15 +268,15 @@ class GraphDDBRuntime:
|
|
|
268
268
|
def use(self, mw: Any) -> None:
|
|
269
269
|
"""Register a host-side middleware (read R1–R5 / write W1–W5 hooks).
|
|
270
270
|
|
|
271
|
-
Mirrors the TS ``
|
|
272
|
-
FIFO ``before*`` order and first in the LIFO ``after`` / ``onError``
|
|
271
|
+
Mirrors the TS ``graphddb.config.use``: appended last, so it runs last in
|
|
272
|
+
the FIFO ``before*`` order and first in the LIFO ``after`` / ``onError``
|
|
273
273
|
order. The middleware is never serialized.
|
|
274
274
|
"""
|
|
275
275
|
self._middleware.use(mw)
|
|
276
276
|
|
|
277
277
|
def clear_middleware(self) -> None:
|
|
278
278
|
"""Remove every registered middleware (teardown / tests). Mirrors the TS
|
|
279
|
-
``
|
|
279
|
+
``graphddb.config.clearMiddleware``."""
|
|
280
280
|
self._middleware.clear()
|
|
281
281
|
|
|
282
282
|
def get_middleware(self) -> Tuple[Any, ...]:
|
|
@@ -589,6 +589,33 @@ class GraphDDBRuntime:
|
|
|
589
589
|
opts = dict(params or {})
|
|
590
590
|
|
|
591
591
|
is_array = isinstance(key_or_keys, (list, tuple))
|
|
592
|
+
|
|
593
|
+
# A **relation-traversal** contract read lowers to >1 operation (the root
|
|
594
|
+
# read + one leg per resolved relation; e.g. getUserByEmail → 4 ops,
|
|
595
|
+
# getGroupAccess → 3 ops). The single-op point/range executors below cannot
|
|
596
|
+
# assemble a multi-op result — that is the job of :meth:`execute_query`,
|
|
597
|
+
# which walks the whole ``operations`` list and merges the relation legs.
|
|
598
|
+
# So delegate a multi-op single-key read to the relation-assembly path (the
|
|
599
|
+
# exact path ``execute_query`` uses), keyed by the method's referenced
|
|
600
|
+
# operation id. An array of keys against such a method is an N+1 fan-out
|
|
601
|
+
# (one full traversal per key), forbidden by the same N+1 rule that rejects
|
|
602
|
+
# a range array — a single-key relation read is the only reachable shape.
|
|
603
|
+
if len(op_spec.get("operations", [])) > 1:
|
|
604
|
+
if is_array:
|
|
605
|
+
raise ContractArityError(
|
|
606
|
+
f"{contract_name}.{method_name}: a relation-traversal method "
|
|
607
|
+
f"(it lowers to multiple operations) was called with an array of "
|
|
608
|
+
f"keys. Each key is a full traversal, so an array would be an N+1 "
|
|
609
|
+
f"fan-out — forbidden by the contract's N+1 rule. Loop in "
|
|
610
|
+
f"application code for N independent reads."
|
|
611
|
+
)
|
|
612
|
+
validate_params(
|
|
613
|
+
key_or_keys,
|
|
614
|
+
op_spec.get("params", {}),
|
|
615
|
+
operation_id=f"{contract_name}.{method_name}",
|
|
616
|
+
)
|
|
617
|
+
return self.execute_query(method["operation"], key_or_keys, opts)
|
|
618
|
+
|
|
592
619
|
if is_array:
|
|
593
620
|
# Only a method whose ``inputArity`` is ``'either'`` coalesces a key
|
|
594
621
|
# array into one batched call. A ``'single'`` method must reject an
|
|
@@ -676,12 +703,17 @@ class GraphDDBRuntime:
|
|
|
676
703
|
def _contract_query_op(
|
|
677
704
|
self, contract_name: str, method_name: str, method: Mapping[str, Any]
|
|
678
705
|
) -> Mapping[str, Any]:
|
|
679
|
-
"""The
|
|
680
|
-
|
|
681
|
-
A
|
|
682
|
-
|
|
683
|
-
the
|
|
684
|
-
|
|
706
|
+
"""The referenced read op spec for a contract query method.
|
|
707
|
+
|
|
708
|
+
A method's referenced operation carries **one or more** read operations: a
|
|
709
|
+
plain point/range read is a single operation, whereas a relation-traversal
|
|
710
|
+
read lowers to the root read PLUS one leg per resolved relation (e.g.
|
|
711
|
+
getUserByEmail → 4 ops). ``execute_query_method`` routes a multi-op spec
|
|
712
|
+
through the relation-assembly path (:meth:`execute_query`); the single-op
|
|
713
|
+
point/range executors handle the ``len == 1`` case. Any External Query
|
|
714
|
+
compositions (#63) a method declares are carried separately on the method
|
|
715
|
+
spec (``compose``) — they do **not** add operations to this op spec (and a
|
|
716
|
+
composed child is always a ``point`` read, i.e. a single operation).
|
|
685
717
|
"""
|
|
686
718
|
op_name = method["operation"]
|
|
687
719
|
spec = self._queries.get(op_name)
|
|
@@ -691,10 +723,10 @@ class GraphDDBRuntime:
|
|
|
691
723
|
f"not present in `queries`."
|
|
692
724
|
)
|
|
693
725
|
operations = spec.get("operations", [])
|
|
694
|
-
if len(operations)
|
|
726
|
+
if len(operations) < 1:
|
|
695
727
|
raise GraphDDBError(
|
|
696
|
-
f"{contract_name}.{method_name}: a
|
|
697
|
-
f"
|
|
728
|
+
f"{contract_name}.{method_name}: a query method must resolve to at "
|
|
729
|
+
f"least one read operation, found {len(operations)}."
|
|
698
730
|
)
|
|
699
731
|
return spec
|
|
700
732
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphddb-runtime"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.1"
|
|
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"
|
|
@@ -166,7 +166,7 @@ def test_independent_relation_stage_runs_concurrently():
|
|
|
166
166
|
|
|
167
167
|
# If the two sibling queries did NOT overlap, the barrier (timeout=3) would
|
|
168
168
|
# raise BrokenBarrierError here. Reaching the assertions proves concurrency.
|
|
169
|
-
out = rt.execute_query("
|
|
169
|
+
out = rt.execute_query("GroupAccessMulti__getGroupAccess", {"groupId": "eng"})
|
|
170
170
|
|
|
171
171
|
assert out["members"]["items"][0]["userId"] == "alice"
|
|
172
172
|
assert out["permissions"]["items"][0]["resource"] == "codebase"
|
|
@@ -192,9 +192,9 @@ def test_stage_concurrency_is_bounded_by_the_declared_limit():
|
|
|
192
192
|
client = _CountingGroupAccessClient()
|
|
193
193
|
rt = GraphDDBRuntime(client, MULTI_MANIFEST, MULTI_OPS)
|
|
194
194
|
# Force the declared bound to 1 on the loaded spec.
|
|
195
|
-
rt._queries["
|
|
195
|
+
rt._queries["GroupAccessMulti__getGroupAccess"]["executionPlan"]["concurrency"] = 1
|
|
196
196
|
|
|
197
|
-
rt.execute_query("
|
|
197
|
+
rt.execute_query("GroupAccessMulti__getGroupAccess", {"groupId": "eng"})
|
|
198
198
|
# Bound 1 → strictly one query at a time.
|
|
199
199
|
assert client.peak_in_flight == 1
|
|
200
200
|
assert client.query_count == 2
|
|
@@ -215,9 +215,9 @@ def test_plan_absent_falls_back_to_sequential():
|
|
|
215
215
|
client.barrier = threading.Barrier(1, timeout=3) # no overlap expected
|
|
216
216
|
rt = GraphDDBRuntime(client, MULTI_MANIFEST, MULTI_OPS)
|
|
217
217
|
# Strip the plan → exercise the backward-compatible (sequential) fallback.
|
|
218
|
-
rt._queries["
|
|
218
|
+
rt._queries["GroupAccessMulti__getGroupAccess"].pop("executionPlan", None)
|
|
219
219
|
|
|
220
|
-
out = rt.execute_query("
|
|
220
|
+
out = rt.execute_query("GroupAccessMulti__getGroupAccess", {"groupId": "eng"})
|
|
221
221
|
assert out["members"]["items"][0]["userId"] == "alice"
|
|
222
222
|
assert out["permissions"]["items"][0]["resource"] == "codebase"
|
|
223
223
|
# Plan-less → each op its own stage → strictly one query in flight at a time.
|
|
@@ -227,7 +227,7 @@ def test_plan_absent_falls_back_to_sequential():
|
|
|
227
227
|
def test_relation_stages_reads_plan_groups():
|
|
228
228
|
"""Unit: `_relation_stages` derives stages from the plan (root dropped)."""
|
|
229
229
|
rt = GraphDDBRuntime(_BarrierGroupAccessClient(), MULTI_MANIFEST, MULTI_OPS)
|
|
230
|
-
spec = rt._queries["
|
|
230
|
+
spec = rt._queries["GroupAccessMulti__getGroupAccess"]
|
|
231
231
|
operations = spec["operations"]
|
|
232
232
|
# Plan present: [[0],[1,2]] → relation stages [[1, 2]] (root index 0 dropped).
|
|
233
233
|
assert rt._relation_stages(spec, operations) == [[1, 2]]
|
|
@@ -243,7 +243,7 @@ def test_relation_stages_reads_plan_groups():
|
|
|
243
243
|
def test_deep_chain_plan_stages_are_sequential():
|
|
244
244
|
"""A result-dependent chain (getUserByEmail) stages into ordered single-op stages."""
|
|
245
245
|
rt = GraphDDBRuntime(_BarrierGroupAccessClient(), MULTI_MANIFEST, MULTI_OPS)
|
|
246
|
-
spec = rt._queries["
|
|
246
|
+
spec = rt._queries["UserByEmailMulti__getUserByEmail"]
|
|
247
247
|
# Plan [[0],[1],[2],[3]] → relation stages [[1],[2],[3]] (each depends on prior).
|
|
248
248
|
assert rt._relation_stages(spec, spec["operations"]) == [[1], [2], [3]]
|
|
249
249
|
|
|
@@ -38,7 +38,7 @@ def test_manifest_carries_entity_and_field_description() -> None:
|
|
|
38
38
|
|
|
39
39
|
def test_operations_carries_query_param_and_contract_description() -> None:
|
|
40
40
|
ops = _operations()
|
|
41
|
-
get_group = ops["queries"]["
|
|
41
|
+
get_group = ops["queries"]["GroupByIdRead__get"]
|
|
42
42
|
assert get_group["description"] == "Fetch a single group by its id."
|
|
43
43
|
assert get_group["params"]["groupId"]["description"] == "The group id to fetch."
|
|
44
44
|
# The contract method description rides the `contracts` map.
|
|
@@ -48,7 +48,7 @@ def test_operations_carries_query_param_and_contract_description() -> None:
|
|
|
48
48
|
|
|
49
49
|
def test_undescribed_query_has_no_description_key() -> None:
|
|
50
50
|
# `getUser` declares no description — backward compatible.
|
|
51
|
-
get_user = _operations()["queries"]["
|
|
51
|
+
get_user = _operations()["queries"]["UserByIdRead__get"]
|
|
52
52
|
assert "description" not in get_user
|
|
53
53
|
|
|
54
54
|
|
|
@@ -63,7 +63,7 @@ def test_manifest_carries_jsdoc_gsi_description() -> None:
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
def test_operations_carry_jsdoc_query_and_param_description() -> None:
|
|
66
|
-
get_user = _operations()["queries"]["
|
|
66
|
+
get_user = _operations()["queries"]["UserDocumentedRead__getUserDocumented"]
|
|
67
67
|
assert get_user["description"] == (
|
|
68
68
|
"#165 JSDoc-as-default (query): fetch a single user by id (JSDoc source)."
|
|
69
69
|
)
|
|
@@ -155,11 +155,11 @@ def test_command_put_then_query_then_delete(client, repos):
|
|
|
155
155
|
membership = repos["membership"]
|
|
156
156
|
|
|
157
157
|
# put (generated command, key attributes injected by the runtime)
|
|
158
|
-
membership.
|
|
159
|
-
membership.
|
|
158
|
+
membership.membership_conf_commands__add_group_member(group_id="eng", user_id="alice", role="admin")
|
|
159
|
+
membership.membership_conf_commands__add_group_member(group_id="eng", user_id="bob", role="member")
|
|
160
160
|
|
|
161
161
|
# query (generated list query, single Query op on the base partition)
|
|
162
|
-
result = repos["membership"].
|
|
162
|
+
result = repos["membership"].members_by_group_read__list(group_id="eng")
|
|
163
163
|
assert result is not None
|
|
164
164
|
by_user = {m["userId"]: m for m in result["items"]}
|
|
165
165
|
assert set(by_user) == {"alice", "bob"}
|
|
@@ -168,17 +168,17 @@ def test_command_put_then_query_then_delete(client, repos):
|
|
|
168
168
|
assert by_user["alice"]["joinedAt"] == datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
169
169
|
|
|
170
170
|
# delete one member, confirm it is gone
|
|
171
|
-
membership.
|
|
172
|
-
after = repos["membership"].
|
|
171
|
+
membership.membership_conf_commands__delete_group_member(group_id="eng", user_id="bob")
|
|
172
|
+
after = repos["membership"].members_by_group_read__list(group_id="eng")
|
|
173
173
|
assert {m["userId"] for m in after["items"]} == {"alice"}
|
|
174
174
|
|
|
175
175
|
# cleanup
|
|
176
|
-
membership.
|
|
176
|
+
membership.membership_conf_commands__delete_group_member(group_id="eng", user_id="alice")
|
|
177
177
|
|
|
178
178
|
|
|
179
179
|
def test_get_item_on_base_table(client, repos):
|
|
180
180
|
_put_group(client, "product", "Product")
|
|
181
|
-
out = repos["group"].
|
|
181
|
+
out = repos["group"].group_by_id_read__get(group_id="product")
|
|
182
182
|
assert out is not None
|
|
183
183
|
assert out["groupId"] == "product"
|
|
184
184
|
assert out["name"] == "Product"
|
|
@@ -188,12 +188,12 @@ def test_get_item_on_base_table(client, repos):
|
|
|
188
188
|
|
|
189
189
|
|
|
190
190
|
def test_get_item_missing_returns_none(repos):
|
|
191
|
-
assert repos["group"].
|
|
191
|
+
assert repos["group"].group_by_id_read__get(group_id="does-not-exist") is None
|
|
192
192
|
|
|
193
193
|
|
|
194
194
|
def test_query_on_unique_gsi(client, repos):
|
|
195
195
|
_put_user(client, "carol", "carol@example.com")
|
|
196
|
-
out = repos["user"].
|
|
196
|
+
out = repos["user"].user_by_email_read__get(email="carol@example.com")
|
|
197
197
|
# getUserByEmail is a relation-free cardinality-one Query: a single object
|
|
198
198
|
# (matching the generated flat GetUserByEmailResult and the TS executor),
|
|
199
199
|
# not a {items, cursor} connection.
|
|
@@ -204,8 +204,8 @@ def test_query_on_unique_gsi(client, repos):
|
|
|
204
204
|
|
|
205
205
|
def test_update_item_round_trip(client, repos):
|
|
206
206
|
_put_user(client, "dave", "dave@example.com", status="active")
|
|
207
|
-
repos["user"].
|
|
208
|
-
out = repos["user"].
|
|
207
|
+
repos["user"].user_conf_commands__disable_user(status="disabled", user_id="dave")
|
|
208
|
+
out = repos["user"].user_by_id_read__get(user_id="dave")
|
|
209
209
|
assert out["status"] == "disabled"
|
|
210
210
|
|
|
211
211
|
|
|
@@ -220,18 +220,18 @@ def test_conditional_put_succeeds_then_fails_on_existing(client, repos):
|
|
|
220
220
|
from graphddb_runtime import OperationExecutionError
|
|
221
221
|
|
|
222
222
|
runtime = repos["runtime"]
|
|
223
|
-
runtime._commands["
|
|
223
|
+
runtime._commands["MembershipConfCommands__addGroupMember"]["condition"] = {"kind": "notExists"}
|
|
224
224
|
try:
|
|
225
225
|
runtime.execute_command(
|
|
226
|
-
"
|
|
226
|
+
"MembershipConfCommands__addGroupMember", {"groupId": "cond", "userId": "once", "role": "member"}
|
|
227
227
|
)
|
|
228
228
|
with pytest.raises(OperationExecutionError):
|
|
229
229
|
runtime.execute_command(
|
|
230
|
-
"
|
|
230
|
+
"MembershipConfCommands__addGroupMember",
|
|
231
231
|
{"groupId": "cond", "userId": "once", "role": "member"},
|
|
232
232
|
)
|
|
233
233
|
finally:
|
|
234
|
-
runtime._commands["
|
|
234
|
+
runtime._commands["MembershipConfCommands__addGroupMember"].pop("condition", None)
|
|
235
235
|
client.delete_item(
|
|
236
236
|
TableName=TABLE,
|
|
237
237
|
Key={"PK": {"S": "GROUP#cond"}, "SK": {"S": "USER#once"}},
|
|
@@ -250,32 +250,32 @@ def test_conditional_update_equality_matches_then_mismatches(client, repos):
|
|
|
250
250
|
runtime = repos["runtime"]
|
|
251
251
|
_put_user(client, "condu", "condu@example.com", status="active")
|
|
252
252
|
# Condition: only disable when the current status equals the param `expect`.
|
|
253
|
-
runtime._commands["
|
|
253
|
+
runtime._commands["UserConfCommands__disableUser"]["condition"] = {
|
|
254
254
|
"kind": "equals",
|
|
255
255
|
"fields": {"status": "{expect}"},
|
|
256
256
|
}
|
|
257
257
|
# Declare the extra param so validate_params accepts `expect`.
|
|
258
|
-
runtime._commands["
|
|
258
|
+
runtime._commands["UserConfCommands__disableUser"]["params"]["expect"] = {
|
|
259
259
|
"type": "string",
|
|
260
260
|
"required": True,
|
|
261
261
|
}
|
|
262
262
|
try:
|
|
263
263
|
# Condition expects current status 'active' → matches → succeeds.
|
|
264
264
|
runtime.execute_command(
|
|
265
|
-
"
|
|
265
|
+
"UserConfCommands__disableUser",
|
|
266
266
|
{"userId": "condu", "status": "disabled", "expect": "active"},
|
|
267
267
|
)
|
|
268
|
-
out = repos["user"].
|
|
268
|
+
out = repos["user"].user_by_id_read__get(user_id="condu")
|
|
269
269
|
assert out["status"] == "disabled"
|
|
270
270
|
# The row's status is now 'disabled', so expecting 'active' must fail.
|
|
271
271
|
with pytest.raises(OperationExecutionError):
|
|
272
272
|
runtime.execute_command(
|
|
273
|
-
"
|
|
273
|
+
"UserConfCommands__disableUser",
|
|
274
274
|
{"userId": "condu", "status": "disabled", "expect": "active"},
|
|
275
275
|
)
|
|
276
276
|
finally:
|
|
277
|
-
runtime._commands["
|
|
278
|
-
runtime._commands["
|
|
277
|
+
runtime._commands["UserConfCommands__disableUser"].pop("condition", None)
|
|
278
|
+
runtime._commands["UserConfCommands__disableUser"]["params"].pop("expect", None)
|
|
279
279
|
client.delete_item(
|
|
280
280
|
TableName=TABLE,
|
|
281
281
|
Key={"PK": {"S": "USER#condu"}, "SK": {"S": "PROFILE"}},
|
|
@@ -297,7 +297,7 @@ def test_transaction_static_round_trip(client, repos):
|
|
|
297
297
|
ConsistentRead=True,
|
|
298
298
|
)["Item"]
|
|
299
299
|
assert member["role"]["S"] == "admin"
|
|
300
|
-
group = repos["group"].
|
|
300
|
+
group = repos["group"].group_by_id_read__get(group_id="txg")
|
|
301
301
|
assert group["name"] == "admin"
|
|
302
302
|
# cleanup
|
|
303
303
|
client.delete_item(
|
|
@@ -322,7 +322,7 @@ def test_transaction_foreach_round_trip(client, repos):
|
|
|
322
322
|
],
|
|
323
323
|
},
|
|
324
324
|
)
|
|
325
|
-
members = repos["membership"].
|
|
325
|
+
members = repos["membership"].members_by_group_read__list(group_id="txm")
|
|
326
326
|
assert {m["userId"] for m in members["items"]} == {"a", "c"}
|
|
327
327
|
for uid in ("a", "c"):
|
|
328
328
|
client.delete_item(
|
|
@@ -366,7 +366,7 @@ def test_transaction_rolls_back_on_condition_failure(client, repos):
|
|
|
366
366
|
{"groupId": "txr", "userId": "txu", "role": "admin"},
|
|
367
367
|
)
|
|
368
368
|
# The group name must be unchanged (whole tx rolled back).
|
|
369
|
-
group = repos["group"].
|
|
369
|
+
group = repos["group"].group_by_id_read__get(group_id="txr")
|
|
370
370
|
assert group["name"] == "Original"
|
|
371
371
|
finally:
|
|
372
372
|
spec["items"][0].pop("condition", None)
|
|
@@ -396,7 +396,7 @@ def test_transaction_condition_check_commits_when_target_exists(client, repos):
|
|
|
396
396
|
assert member is not None
|
|
397
397
|
assert member["role"]["S"] == "member"
|
|
398
398
|
# The asserted group row is untouched (ConditionCheck mutates nothing).
|
|
399
|
-
group = repos["group"].
|
|
399
|
+
group = repos["group"].group_by_id_read__get(group_id="ccok")
|
|
400
400
|
assert group["name"] == "CcOkGroup"
|
|
401
401
|
# cleanup
|
|
402
402
|
client.delete_item(
|
|
@@ -442,12 +442,12 @@ def test_query_pagination_cursor(client, repos):
|
|
|
442
442
|
# Seed three memberships under a fresh group and page with limit-less query;
|
|
443
443
|
# then page explicitly using the runtime cursor to confirm round-trip.
|
|
444
444
|
for uid in ("u1", "u2", "u3"):
|
|
445
|
-
repos["membership"].
|
|
445
|
+
repos["membership"].membership_conf_commands__add_group_member(group_id="pag", user_id=uid, role="member")
|
|
446
446
|
|
|
447
447
|
runtime = repos["runtime"]
|
|
448
|
-
page1 = runtime.execute_query("
|
|
448
|
+
page1 = runtime.execute_query("MembersByGroupRead__list", {"groupId": "pag"})
|
|
449
449
|
# DynamoDB Local returns all small items in one page; cursor may be None.
|
|
450
450
|
assert {m["userId"] for m in page1["items"]} == {"u1", "u2", "u3"}
|
|
451
451
|
|
|
452
452
|
for uid in ("u1", "u2", "u3"):
|
|
453
|
-
repos["membership"].
|
|
453
|
+
repos["membership"].membership_conf_commands__delete_group_member(group_id="pag", user_id=uid)
|
|
@@ -178,12 +178,12 @@ def _raw(client, pk, sk):
|
|
|
178
178
|
|
|
179
179
|
|
|
180
180
|
def _members(runtime, group_id):
|
|
181
|
-
out = runtime.execute_query("
|
|
181
|
+
out = runtime.execute_query("GroupMembersMulti__getGroupMembers", {"groupId": group_id})
|
|
182
182
|
return {m["userId"] for m in out["members"]["items"]}
|
|
183
183
|
|
|
184
184
|
|
|
185
185
|
def _groups(runtime, user_id):
|
|
186
|
-
out = runtime.execute_query("
|
|
186
|
+
out = runtime.execute_query("UserGroupsMulti__getUserGroups", {"userId": user_id})
|
|
187
187
|
return {g["groupId"] for g in out["groups"]["items"]}
|
|
188
188
|
|
|
189
189
|
|
|
@@ -167,7 +167,7 @@ def test_r2_filter_narrows_permissions_fan_out_end_to_end(multi_rt):
|
|
|
167
167
|
ctx.operation["ExpressionAttributeValues"][":mwa"] = {"S": "read"}
|
|
168
168
|
|
|
169
169
|
multi_rt.use({"read": {"op": {"before": op_before}}})
|
|
170
|
-
out = multi_rt.execute_query("
|
|
170
|
+
out = multi_rt.execute_query("GroupAccessMulti__getGroupAccess", {"groupId": "eng"})
|
|
171
171
|
actions = sorted(p["action"] for p in out["permissions"]["items"])
|
|
172
172
|
assert actions == ["read"] # 'write' filtered out by DynamoDB
|
|
173
173
|
# Members are untouched (the filter scoped only the permissions leg).
|
|
@@ -176,7 +176,7 @@ def test_r2_filter_narrows_permissions_fan_out_end_to_end(multi_rt):
|
|
|
176
176
|
|
|
177
177
|
def test_r4_transforms_final_result_end_to_end(multi_rt):
|
|
178
178
|
multi_rt.use({"read": {"afterFetch": lambda ctx, r: {**r, "redacted": True}}})
|
|
179
|
-
out = multi_rt.execute_query("
|
|
179
|
+
out = multi_rt.execute_query("GroupAccessMulti__getGroupAccess", {"groupId": "eng"})
|
|
180
180
|
assert out["redacted"] is True
|
|
181
181
|
assert out["groupId"] == "eng"
|
|
182
182
|
|
|
@@ -192,7 +192,7 @@ def test_r5_op_recovery_substitutes_failed_fan_out_end_to_end(multi_rt):
|
|
|
192
192
|
return None
|
|
193
193
|
|
|
194
194
|
multi_rt.use({"read": {"op": {"before": op_before, "onError": op_error}}})
|
|
195
|
-
out = multi_rt.execute_query("
|
|
195
|
+
out = multi_rt.execute_query("GroupAccessMulti__getGroupAccess", {"groupId": "eng"})
|
|
196
196
|
assert [m["userId"] for m in out["members"]["items"]] == ["fallback"]
|
|
197
197
|
|
|
198
198
|
|
|
@@ -202,7 +202,7 @@ def test_r5_op_recovery_substitutes_failed_fan_out_end_to_end(multi_rt):
|
|
|
202
202
|
def test_w1_soft_delete_rewrite_stores_update_row_survives(std_rt):
|
|
203
203
|
# Seed a membership to soft-delete.
|
|
204
204
|
std_rt.execute_command(
|
|
205
|
-
"
|
|
205
|
+
"MembershipConfCommands__addGroupMember", {"groupId": "eng", "userId": "carol", "role": "member"}
|
|
206
206
|
)
|
|
207
207
|
|
|
208
208
|
def before(ctx):
|
|
@@ -211,7 +211,7 @@ def test_w1_soft_delete_rewrite_stores_update_row_survives(std_rt):
|
|
|
211
211
|
ctx.input["changes"] = {"role": "SOFT-DELETED"}
|
|
212
212
|
|
|
213
213
|
std_rt.use({"write": {"before": before}})
|
|
214
|
-
std_rt.execute_command("
|
|
214
|
+
std_rt.execute_command("MembershipConfCommands__deleteGroupMember", {"groupId": "eng", "userId": "carol"})
|
|
215
215
|
|
|
216
216
|
# The row SURVIVES (soft-deleted): a delete would have removed it.
|
|
217
217
|
raw = std_rt._client.get_item(
|
|
@@ -233,7 +233,7 @@ def test_w3_mutation_persisted_on_real_write(std_rt):
|
|
|
233
233
|
|
|
234
234
|
std_rt.use({"write": {"persist": {"before": persist_before}}})
|
|
235
235
|
std_rt.execute_command(
|
|
236
|
-
"
|
|
236
|
+
"MembershipConfCommands__addGroupMember", {"groupId": "eng", "userId": "dave", "role": "member"}
|
|
237
237
|
)
|
|
238
238
|
raw = std_rt._client.get_item(
|
|
239
239
|
TableName=TABLE,
|
|
@@ -255,7 +255,7 @@ def test_w5_persist_recovery_suppresses_failure(std_rt):
|
|
|
255
255
|
}}})
|
|
256
256
|
# Recovery suppresses the throw; the row is NOT written (send never ran).
|
|
257
257
|
std_rt.execute_command(
|
|
258
|
-
"
|
|
258
|
+
"MembershipConfCommands__addGroupMember", {"groupId": "eng", "userId": "erin", "role": "member"}
|
|
259
259
|
)
|
|
260
260
|
raw = std_rt._client.get_item(
|
|
261
261
|
TableName=TABLE,
|
|
@@ -272,12 +272,12 @@ def test_w2_put_real_old_image_end_to_end(std_rt):
|
|
|
272
272
|
# overwrites it. W2 sees the exact prior row as oldImage (real ALL_OLD from
|
|
273
273
|
# DynamoDB Local) and the written item as newImage.
|
|
274
274
|
std_rt.execute_command(
|
|
275
|
-
"
|
|
275
|
+
"MembershipConfCommands__addGroupMember", {"groupId": "eng", "userId": "frank", "role": "member"}
|
|
276
276
|
)
|
|
277
277
|
captured = {}
|
|
278
278
|
std_rt.use({"write": {"after": lambda ctx, change: captured.update(change)}})
|
|
279
279
|
std_rt.execute_command(
|
|
280
|
-
"
|
|
280
|
+
"MembershipConfCommands__addGroupMember", {"groupId": "eng", "userId": "frank", "role": "admin"}
|
|
281
281
|
)
|
|
282
282
|
assert captured["oldImage"]["role"] == "member" # real pre-write row
|
|
283
283
|
assert captured["newImage"]["role"] == "admin" # the written item
|
|
@@ -285,11 +285,11 @@ def test_w2_put_real_old_image_end_to_end(std_rt):
|
|
|
285
285
|
|
|
286
286
|
def test_w2_delete_real_old_image_end_to_end(std_rt):
|
|
287
287
|
std_rt.execute_command(
|
|
288
|
-
"
|
|
288
|
+
"MembershipConfCommands__addGroupMember", {"groupId": "eng", "userId": "gina", "role": "member"}
|
|
289
289
|
)
|
|
290
290
|
captured = {}
|
|
291
291
|
std_rt.use({"write": {"after": lambda ctx, change: captured.update(seen=change)}})
|
|
292
|
-
std_rt.execute_command("
|
|
292
|
+
std_rt.execute_command("MembershipConfCommands__deleteGroupMember", {"groupId": "eng", "userId": "gina"})
|
|
293
293
|
change = captured["seen"]
|
|
294
294
|
assert change["oldImage"]["userId"] == "gina"
|
|
295
295
|
assert change["oldImage"]["role"] == "member"
|
|
@@ -227,7 +227,7 @@ def env(seeded):
|
|
|
227
227
|
|
|
228
228
|
|
|
229
229
|
def test_get_group_access_members_and_permissions(env):
|
|
230
|
-
out = env["group"].
|
|
230
|
+
out = env["group"].group_access_multi__get_group_access(group_id="eng")
|
|
231
231
|
assert out is not None
|
|
232
232
|
assert out["groupId"] == "eng"
|
|
233
233
|
assert out["name"] == "Engineering"
|
|
@@ -249,7 +249,7 @@ def test_get_group_access_members_and_permissions(env):
|
|
|
249
249
|
|
|
250
250
|
|
|
251
251
|
def test_get_user_by_email_deep_traversal(env):
|
|
252
|
-
out = env["user"].
|
|
252
|
+
out = env["user"].user_by_email_multi__get_user_by_email(email="alice@example.com")
|
|
253
253
|
assert out is not None
|
|
254
254
|
# cardinality 'one' → root is the User object, not a connection.
|
|
255
255
|
assert out["userId"] == "alice"
|
|
@@ -276,7 +276,7 @@ def test_get_user_by_email_deep_traversal(env):
|
|
|
276
276
|
def test_belongs_to_resolution_is_single_batch_get_no_n_plus_one(env):
|
|
277
277
|
spy = env["spy"]
|
|
278
278
|
spy.reset()
|
|
279
|
-
env["user"].
|
|
279
|
+
env["user"].user_by_email_multi__get_user_by_email(email="alice@example.com")
|
|
280
280
|
|
|
281
281
|
# The belongsTo parent-Group resolution for the 2 memberships is ONE
|
|
282
282
|
# BatchGetItem, and NOT a single individual GetItem (N+1 not issued).
|
|
@@ -287,7 +287,7 @@ def test_belongs_to_resolution_is_single_batch_get_no_n_plus_one(env):
|
|
|
287
287
|
def test_user_with_no_groups_returns_empty_connection(env):
|
|
288
288
|
# bob is seeded; a fresh user with no memberships still resolves cleanly.
|
|
289
289
|
env["runtime"] # warm
|
|
290
|
-
out = env["user"].
|
|
290
|
+
out = env["user"].user_by_email_multi__get_user_by_email(email="bob@example.com")
|
|
291
291
|
groups = {g["groupId"]: g for g in out["groups"]["items"]}
|
|
292
292
|
assert set(groups) == {"eng", "product"}
|
|
293
293
|
|
|
@@ -304,7 +304,7 @@ def test_depth_limit_exceeded_is_runtime_error(seeded):
|
|
|
304
304
|
)
|
|
305
305
|
bridge = _load_generated_package()
|
|
306
306
|
with pytest.raises(LimitExceededError):
|
|
307
|
-
bridge.UserRepository(runtime).
|
|
307
|
+
bridge.UserRepository(runtime).user_by_email_multi__get_user_by_email(email="alice@example.com")
|
|
308
308
|
|
|
309
309
|
|
|
310
310
|
def test_operations_limit_exceeded_is_runtime_error(seeded):
|
|
@@ -316,7 +316,7 @@ def test_operations_limit_exceeded_is_runtime_error(seeded):
|
|
|
316
316
|
)
|
|
317
317
|
bridge = _load_generated_package()
|
|
318
318
|
with pytest.raises(LimitExceededError):
|
|
319
|
-
bridge.UserRepository(runtime).
|
|
319
|
+
bridge.UserRepository(runtime).user_by_email_multi__get_user_by_email(email="alice@example.com")
|
|
320
320
|
|
|
321
321
|
|
|
322
322
|
def test_explain_lists_operations_without_touching_dynamodb(seeded):
|
|
@@ -327,7 +327,7 @@ def test_explain_lists_operations_without_touching_dynamodb(seeded):
|
|
|
327
327
|
operations_path=MULTI_OPS,
|
|
328
328
|
limits=RuntimeLimits(max_depth=3),
|
|
329
329
|
)
|
|
330
|
-
plan = runtime.explain("
|
|
330
|
+
plan = runtime.explain("UserByEmailMulti__getUserByEmail", {"email": "alice@example.com"})
|
|
331
331
|
assert len(plan["operations"]) == 4
|
|
332
332
|
assert plan["operations"][0]["keyCondition"]["GSI1PK"] == "EMAIL#alice@example.com"
|
|
333
333
|
assert plan["operations"][2]["type"] == "BatchGetItem"
|
|
@@ -342,7 +342,7 @@ def test_refs_fan_out_resolves_inline_id_list_in_one_batch_get(env):
|
|
|
342
342
|
repo = bridge.ArticleRepository(env["runtime"])
|
|
343
343
|
env["spy"].reset()
|
|
344
344
|
|
|
345
|
-
out = repo.
|
|
345
|
+
out = repo.article_with_tags_multi__get_article_with_tags(article_id="a1")
|
|
346
346
|
assert out is not None
|
|
347
347
|
assert out["articleId"] == "a1"
|
|
348
348
|
assert out["title"] == "Refs!"
|
|
@@ -368,7 +368,7 @@ def test_refs_empty_list_resolves_empty_connection_without_batch_get(env):
|
|
|
368
368
|
repo = bridge.ArticleRepository(env["runtime"])
|
|
369
369
|
env["spy"].reset()
|
|
370
370
|
|
|
371
|
-
out = repo.
|
|
371
|
+
out = repo.article_with_tags_multi__get_article_with_tags(article_id="a2")
|
|
372
372
|
assert out is not None
|
|
373
373
|
assert out["tags"] == {"items": [], "cursor": None}
|
|
374
374
|
assert "tagRefs" not in out
|