graphddb-runtime 0.2.1__tar.gz → 0.2.3__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.1 → graphddb_runtime-0.2.3}/PKG-INFO +1 -1
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/runtime.py +71 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/transactions.py +16 -4
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime.egg-info/PKG-INFO +1 -1
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/pyproject.toml +1 -1
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_unit.py +103 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/README.md +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/__init__.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/async_runtime.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/batch.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/concurrency.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/cursor.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/errors.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/filters.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/hydration.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/limits.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/per_key_cursor.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/relations.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime/templates.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime.egg-info/SOURCES.txt +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime.egg-info/requires.txt +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/graphddb_runtime.egg-info/top_level.txt +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/setup.cfg +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_concurrency.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_contract_runtime.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_integration.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_integration_command.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_integration_compose.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_integration_contract.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_integration_edge_derive.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_integration_edge_write.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_integration_events.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_integration_referential.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_integration_relations.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_integration_unique.py +0 -0
- {graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/tests/test_relations.py +0 -0
|
@@ -1649,6 +1649,17 @@ class GraphDDBRuntime:
|
|
|
1649
1649
|
values[v] = self._serializer.serialize(resolve_template(tmpl, params))
|
|
1650
1650
|
sets.append(f"{n} = {v}")
|
|
1651
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
|
+
|
|
1652
1663
|
request: Dict[str, Any] = {
|
|
1653
1664
|
"TableName": self._physical_table(spec["tableName"]),
|
|
1654
1665
|
"Key": key,
|
|
@@ -1660,6 +1671,66 @@ class GraphDDBRuntime:
|
|
|
1660
1671
|
self._apply_condition(spec, request, params)
|
|
1661
1672
|
self._call(command_id, self._client.update_item, request)
|
|
1662
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
|
+
|
|
1663
1734
|
def _run_delete_item(
|
|
1664
1735
|
self, command_id: str, spec: Mapping[str, Any], params: Mapping[str, Any]
|
|
1665
1736
|
) -> None:
|
|
@@ -251,6 +251,12 @@ def _collapse_same_key_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]
|
|
|
251
251
|
the ADD — the exact silent-drop #93 forbids — so this and any other unhandled same-key
|
|
252
252
|
op combination raise rather than collapse.
|
|
253
253
|
|
|
254
|
+
The ``Delete``+``Put`` and ``Put``+``Update`` branches each require the bucket be EXACTLY
|
|
255
|
+
that pair (no third op kind). A 3-way ``Delete``+``Put``+``Update`` collision (a
|
|
256
|
+
hand-written self-contradictory transaction that deletes, puts, AND increments one row)
|
|
257
|
+
is therefore NOT absorbed by the swap no-op nor the Put+Update reject — it reaches the
|
|
258
|
+
catch-all loud reject below rather than silently dropping the ``Update`` (issue #96).
|
|
259
|
+
|
|
254
260
|
A genuine swap (``old != new``) resolves to two distinct keys, so both survive.
|
|
255
261
|
First-seen order is preserved (a merged ADD takes the position of the FIRST update
|
|
256
262
|
in its bucket), so the output is identical to the TS runtimes' for the same SSoT.
|
|
@@ -267,11 +273,17 @@ def _collapse_same_key_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]
|
|
|
267
273
|
out.append(item)
|
|
268
274
|
continue
|
|
269
275
|
kinds = {_item_kind(b) for b in bucket}
|
|
270
|
-
if "Delete"
|
|
271
|
-
#
|
|
276
|
+
if kinds == {"Delete", "Put"}:
|
|
277
|
+
# EXACTLY {Delete, Put} — a swap pair resolved to one key → net no-op: drop
|
|
278
|
+
# EVERY member at this key. The exact-set guard is load-bearing: a 3-way bucket
|
|
279
|
+
# that ALSO carries an Update (a hand-written self-contradictory transaction
|
|
280
|
+
# that deletes, puts, AND increments the SAME row) is NOT a swap no-op —
|
|
281
|
+
# dropping it here would silently swallow the Update. Such a 3-way collision
|
|
282
|
+
# falls through to the catch-all reject below (issue #96).
|
|
272
283
|
continue
|
|
273
|
-
if "Put"
|
|
274
|
-
#
|
|
284
|
+
if kinds == {"Put", "Update"}:
|
|
285
|
+
# EXACTLY {Put, Update}: base entity Put + derived counter Update(ADD) on the
|
|
286
|
+
# SAME row (issue #93,
|
|
275
287
|
# the aliased self-target that evades the build-time guard). A Put+Update on
|
|
276
288
|
# one item is unsupported (DynamoDB rejects touching one key twice) and
|
|
277
289
|
# collapsing it would silently drop the increment — FAIL LOUDLY instead.
|
|
@@ -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.3"
|
|
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)
|
|
@@ -722,6 +770,61 @@ def test_collapse_unhandled_same_key_combination_rejects_loudly():
|
|
|
722
770
|
_collapse_same_key_items([delete, update])
|
|
723
771
|
|
|
724
772
|
|
|
773
|
+
def test_collapse_three_way_delete_put_update_rejects_loudly():
|
|
774
|
+
"""A 3-way ``Delete``+``Put``+``Update`` on ONE physical row (a hand-written
|
|
775
|
+
self-contradictory transaction that deletes, puts, AND increments the same row).
|
|
776
|
+
Pre-#96 the ``Delete``+``Put`` swap no-op branch fired first and silently dropped the
|
|
777
|
+
``Update`` too. The shared rule now requires that branch be EXACTLY {Delete, Put}, so the
|
|
778
|
+
3-way falls through to the catch-all loud reject. Mirrors the TS ``collapseSameKey``."""
|
|
779
|
+
import pytest
|
|
780
|
+
from graphddb_runtime.transactions import _collapse_same_key_items
|
|
781
|
+
|
|
782
|
+
delete = _delete("T", "GROUP#eng", "META")
|
|
783
|
+
put = _put("T", "GROUP#eng", "META") # SAME key
|
|
784
|
+
add = _add_update("T", "GROUP#eng", "META", "memberCount", 1) # SAME key — the Update
|
|
785
|
+
with pytest.raises(ValueError, match=r"unsupported op combination \(Delete\+Put\+Update\)"):
|
|
786
|
+
_collapse_same_key_items([delete, put, add])
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def test_expand_three_way_delete_put_update_rejects_loudly():
|
|
790
|
+
"""End-to-end through ``TransactionExpander.expand`` (the public, spec-driven path, the
|
|
791
|
+
Python mirror of the TS ``expandTransaction``): a 3-way ``Delete``+``Put``+``Update``
|
|
792
|
+
resolving to ONE marker row must throw rather than silently drop the ``Update`` (#96)."""
|
|
793
|
+
import pytest
|
|
794
|
+
from graphddb_runtime.transactions import TransactionExpander
|
|
795
|
+
|
|
796
|
+
rt = make_runtime(FakeClient())
|
|
797
|
+
expander = TransactionExpander(rt)
|
|
798
|
+
spec = {
|
|
799
|
+
"items": [
|
|
800
|
+
{
|
|
801
|
+
"type": "Delete",
|
|
802
|
+
"tableName": "UniqTbl",
|
|
803
|
+
"entity": "__marker__",
|
|
804
|
+
"literalKey": True,
|
|
805
|
+
"keyCondition": {"PK": "ROW#{rowId}", "SK": "META"},
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
"type": "Put",
|
|
809
|
+
"tableName": "UniqTbl",
|
|
810
|
+
"entity": "__marker__",
|
|
811
|
+
"literalKey": True,
|
|
812
|
+
"item": {"PK": "ROW#{rowId}", "SK": "META"},
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
"type": "Update",
|
|
816
|
+
"tableName": "UniqTbl",
|
|
817
|
+
"entity": "__marker__",
|
|
818
|
+
"literalKey": True,
|
|
819
|
+
"keyCondition": {"PK": "ROW#{rowId}", "SK": "META"},
|
|
820
|
+
"add": {"hits": "1"},
|
|
821
|
+
},
|
|
822
|
+
]
|
|
823
|
+
}
|
|
824
|
+
with pytest.raises(ValueError, match=r"unsupported op combination \(Delete\+Put\+Update\)"):
|
|
825
|
+
expander.expand(spec, {"rowId": "r1"})
|
|
826
|
+
|
|
827
|
+
|
|
725
828
|
def test_expand_collapses_unchanged_unique_guard_swap_to_no_op():
|
|
726
829
|
"""End-to-end through ``TransactionExpander.expand``: a literalKey guard swap whose
|
|
727
830
|
``old.* == input.*`` collapses to a net no-op, leaving only the base write — the
|
|
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
|
{graphddb_runtime-0.2.1 → graphddb_runtime-0.2.3}/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
|