graphddb-runtime 0.8.1__tar.gz → 0.8.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.8.1 → graphddb_runtime-0.8.3}/PKG-INFO +1 -1
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/runtime.py +60 -20
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/templates.py +25 -1
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/transactions.py +23 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime.egg-info/PKG-INFO +1 -1
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/pyproject.toml +1 -1
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_command.py +86 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_unit.py +150 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/README.md +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/__init__.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/async_runtime.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/batch.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/concurrency.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/cursor.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/errors.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/filters.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/hydration.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/limits.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/middleware.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/per_key_cursor.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/relations.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime.egg-info/SOURCES.txt +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime.egg-info/requires.txt +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime.egg-info/top_level.txt +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/setup.cfg +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_concurrency.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_contract_runtime.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_crosslang_python_cells.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_description.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_description_followups.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_description_jsdoc.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_guard_eval.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_binding_contract.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_compose.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_contract.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_edge_derive.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_edge_write.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_events.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_maintain.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_middleware.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_referential.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_relations.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_unique.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_maintain.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_middleware.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_relations.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_spec_version_guard.py +0 -0
- {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_ttl.py +0 -0
|
@@ -54,6 +54,7 @@ from .per_key_cursor import (
|
|
|
54
54
|
from .relations import RelationAssembler, collect_parents, parse_result_path
|
|
55
55
|
from .templates import (
|
|
56
56
|
resolve_template,
|
|
57
|
+
_key_field_names,
|
|
57
58
|
validate_params,
|
|
58
59
|
)
|
|
59
60
|
from .transactions import MAX_TRANSACT_ITEMS, TransactionExpander
|
|
@@ -2397,22 +2398,32 @@ class GraphDDBRuntime:
|
|
|
2397
2398
|
names: Dict[str, str] = {}
|
|
2398
2399
|
values: Dict[str, Any] = {}
|
|
2399
2400
|
sets: List[str] = []
|
|
2401
|
+
# Each change's new value is resolved ONCE here and reused for both the SET
|
|
2402
|
+
# clause and the GSI availability map below (they must agree by construction).
|
|
2403
|
+
resolved_changes: Dict[str, Any] = {}
|
|
2400
2404
|
for i, (field, tmpl) in enumerate(spec.get("changes", {}).items()):
|
|
2401
2405
|
n = f"#c{i}"
|
|
2402
2406
|
v = f":c{i}"
|
|
2403
2407
|
names[n] = field
|
|
2404
|
-
|
|
2408
|
+
resolved = resolve_template(tmpl, params)
|
|
2409
|
+
resolved_changes[field] = resolved
|
|
2410
|
+
values[v] = self._serializer.serialize(resolved)
|
|
2405
2411
|
sets.append(f"{n} = {v}")
|
|
2406
2412
|
|
|
2407
2413
|
# GSI key re-derivation (issue #115): an UpdateItem that changes a GSI's
|
|
2408
2414
|
# composing field MUST re-derive its `<index>PK`/`<index>SK` or the index
|
|
2409
|
-
#
|
|
2410
|
-
#
|
|
2411
|
-
#
|
|
2412
|
-
#
|
|
2413
|
-
# throws (mirroring the base-table partial-key throw).
|
|
2415
|
+
# rots — exactly as the TS `buildUpdateInput` does. A GSI is affected when
|
|
2416
|
+
# at least one of its composing fields is changed. An affected GSI whose
|
|
2417
|
+
# composing fields are not all available throws (mirroring the base-table
|
|
2418
|
+
# partial-key throw).
|
|
2414
2419
|
self._append_gsi_rederive_sets(
|
|
2415
|
-
command_id,
|
|
2420
|
+
command_id,
|
|
2421
|
+
spec.get("entity"),
|
|
2422
|
+
resolved_changes,
|
|
2423
|
+
{f: params[f] for f in _key_field_names(spec.get("keyCondition")) if f in params},
|
|
2424
|
+
names,
|
|
2425
|
+
values,
|
|
2426
|
+
sets,
|
|
2416
2427
|
)
|
|
2417
2428
|
|
|
2418
2429
|
request: Dict[str, Any] = {
|
|
@@ -2437,41 +2448,70 @@ class GraphDDBRuntime:
|
|
|
2437
2448
|
|
|
2438
2449
|
def _append_gsi_rederive_sets(
|
|
2439
2450
|
self,
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2451
|
+
context: str,
|
|
2452
|
+
entity_name: Optional[str],
|
|
2453
|
+
resolved_changes: Mapping[str, Any],
|
|
2454
|
+
available_base: Mapping[str, Any],
|
|
2443
2455
|
names: Dict[str, str],
|
|
2444
2456
|
values: Dict[str, Any],
|
|
2445
2457
|
sets: List[str],
|
|
2446
2458
|
) -> None:
|
|
2447
|
-
"""Re-derive every affected GSI key into
|
|
2459
|
+
"""Re-derive every affected GSI key into an UpdateExpression (issue #115).
|
|
2460
|
+
|
|
2461
|
+
THE single place an update re-derives GSI keys. Both write paths compose
|
|
2462
|
+
through it — the single-op ``UpdateItem`` and the transaction/batch
|
|
2463
|
+
``Update`` item — so a transacted update can never write a stale index
|
|
2464
|
+
that the equivalent single write would have refreshed (issue #377); the TS
|
|
2465
|
+
side likewise routes both through ``buildUpdateInput``.
|
|
2448
2466
|
|
|
2449
2467
|
Mirrors the TS ``buildUpdateInput``: a GSI is *affected* when at least one
|
|
2450
2468
|
of its ``inputFields`` is a changed field; an affected GSI re-derives its
|
|
2451
|
-
``<index>PK``/``<index>SK`` from the available
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2469
|
+
``<index>PK``/``<index>SK`` from the available values. An affected GSI whose
|
|
2470
|
+
``inputFields`` are not all available throws — the SAME explicit error the
|
|
2471
|
+
TS path raises — rather than letting the index rot.
|
|
2472
|
+
|
|
2473
|
+
``resolved_changes`` are the changed fields' ALREADY-RESOLVED new values
|
|
2474
|
+
(the caller resolved them for the SET clause, so the two agree by
|
|
2475
|
+
construction); ``available_base`` is the surrounding value scope — caller
|
|
2476
|
+
params, plus the fan-out ``element`` fields for a transaction item.
|
|
2455
2477
|
"""
|
|
2456
|
-
entity_meta = self._entities.get(
|
|
2478
|
+
entity_meta = self._entities.get(entity_name or "", {})
|
|
2457
2479
|
gsis = entity_meta.get("gsis", [])
|
|
2458
2480
|
if not gsis:
|
|
2459
2481
|
return
|
|
2460
|
-
changed = set(
|
|
2482
|
+
changed = set(resolved_changes.keys())
|
|
2461
2483
|
if not changed:
|
|
2462
2484
|
return
|
|
2463
|
-
# Available value map:
|
|
2464
|
-
|
|
2485
|
+
# Available value map: the surrounding scope (non-None) overlaid with each
|
|
2486
|
+
# change's resolved new value. A changed GSI-composing field thus supplies
|
|
2487
|
+
# its value whether it arrives as a param (``{status}``) or a baked literal
|
|
2488
|
+
# (``running``) — mirroring the TS ``buildUpdateInput``
|
|
2489
|
+
# (``available = { ...keyFields, ...serializedChanges }``, changes win).
|
|
2490
|
+
# Without the overlay a literal-changed field counts as "no value", so the
|
|
2491
|
+
# GSI is never re-derived from it: the write is refused as if the value were
|
|
2492
|
+
# missing — or, where a same-named param exists, the key is composed from
|
|
2493
|
+
# that stale value and the index rots (issue #377).
|
|
2494
|
+
available = {k: v for k, v in available_base.items() if v is not None}
|
|
2495
|
+
available.update(resolved_changes)
|
|
2465
2496
|
gsi_index = 0
|
|
2466
2497
|
for gsi in gsis:
|
|
2498
|
+
index = gsi["indexName"]
|
|
2467
2499
|
input_fields = list(gsi.get("inputFields", []))
|
|
2468
2500
|
if not any(f in changed for f in input_fields):
|
|
2469
2501
|
continue # unaffected GSI — never touched (non-regression).
|
|
2502
|
+
# An index whose key attributes the update ALREADY sets explicitly is
|
|
2503
|
+
# spoken for: `defineTransaction` derives `<index>PK`/`<index>SK` into
|
|
2504
|
+
# `changes` when the spec is built, and every runtime (TS included)
|
|
2505
|
+
# applies those verbatim. Deriving them again here would SET the same
|
|
2506
|
+
# attribute twice in one UpdateExpression, which DynamoDB rejects as
|
|
2507
|
+
# overlapping document paths.
|
|
2508
|
+
if f"{index}PK" in changed or f"{index}SK" in changed:
|
|
2509
|
+
continue
|
|
2470
2510
|
missing = [f for f in input_fields if f not in available]
|
|
2471
2511
|
if missing:
|
|
2472
2512
|
changed_in_gsi = [f for f in input_fields if f in changed]
|
|
2473
2513
|
raise GraphDDBError(
|
|
2474
|
-
f"{
|
|
2514
|
+
f"{context}: updating "
|
|
2475
2515
|
f"{', '.join(repr(f) for f in changed_in_gsi)} affects index "
|
|
2476
2516
|
f"'{gsi['indexName']}' (also depends on "
|
|
2477
2517
|
f"{', '.join(repr(f) for f in missing)}); provide "
|
|
@@ -14,7 +14,7 @@ serialized ``Date`` like ``1970-01-01T00:00:00.000Z``).
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import re
|
|
17
|
-
from typing import Any, Mapping
|
|
17
|
+
from typing import Any, List, Mapping, Optional
|
|
18
18
|
|
|
19
19
|
from behavior_contracts import TemplateFailure as _TemplateFailure
|
|
20
20
|
from behavior_contracts import render_template as _render_template
|
|
@@ -125,6 +125,30 @@ def _check_value(
|
|
|
125
125
|
)
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def _key_field_names(key_condition: Optional[Mapping[str, Any]]) -> List[str]:
|
|
129
|
+
"""The entity fields a write's key pins, read off its ``keyCondition`` templates.
|
|
130
|
+
|
|
131
|
+
These are the only field values a keyed write KNOWS the row will carry (besides
|
|
132
|
+
the changes it makes), so they — not the caller's whole param bag — are the base
|
|
133
|
+
of the GSI availability map (issue #377). A param that merely feeds a write
|
|
134
|
+
CONDITION says nothing about the row's contents, and composing an index key from
|
|
135
|
+
it would point the index at a value the row does not have. This matches the TS
|
|
136
|
+
``buildUpdateInput``, whose availability base is the update key's fields.
|
|
137
|
+
|
|
138
|
+
A fan-out ``{item.field}`` placeholder names the element field, so the leading
|
|
139
|
+
``item.`` scope is stripped.
|
|
140
|
+
"""
|
|
141
|
+
out: List[str] = []
|
|
142
|
+
for template in (key_condition or {}).values():
|
|
143
|
+
for match in _PLACEHOLDER.finditer(str(template)):
|
|
144
|
+
name = match.group(1)
|
|
145
|
+
if name.startswith("item."):
|
|
146
|
+
name = name[len("item.") :]
|
|
147
|
+
if name not in out:
|
|
148
|
+
out.append(name)
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
128
152
|
def has_result_placeholder(template: str) -> bool:
|
|
129
153
|
"""True if the template references a prior result (``{result.*}``)."""
|
|
130
154
|
return any(m.group(1).startswith("result.") for m in _PLACEHOLDER.finditer(template))
|
|
@@ -22,6 +22,7 @@ from behavior_contracts import ExprFailure as _ExprFailure
|
|
|
22
22
|
from behavior_contracts import evaluate_expression as _evaluate_expression
|
|
23
23
|
|
|
24
24
|
from .filters import compile_filter
|
|
25
|
+
from .templates import _key_field_names
|
|
25
26
|
|
|
26
27
|
_PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
|
|
27
28
|
_WHOLE_PLACEHOLDER_RE = re.compile(r"^\{[^{}]+\}$")
|
|
@@ -717,6 +718,28 @@ class TransactionExpander:
|
|
|
717
718
|
# template rendered it to a string (the literal `"1"` / `"-1"`).
|
|
718
719
|
values[v] = self._serializer.serialize(int(value) if isinstance(value, str) else value)
|
|
719
720
|
add_clauses.append(f"{n} {v}")
|
|
721
|
+
# GSI key re-derivation (#377): a transacted `Update` that changes a GSI's
|
|
722
|
+
# composing field must refresh `<index>PK`/`<index>SK` exactly as the
|
|
723
|
+
# single-op `UpdateItem` does — otherwise the same logical write leaves a
|
|
724
|
+
# stale index purely because it rode inside a transaction. Delegates to the
|
|
725
|
+
# runtime's SINGLE re-derivation helper (no second implementation). The
|
|
726
|
+
# available scope is `params ∪ element ∪ resolved changes`, so a fan-out
|
|
727
|
+
# item's `{item.field}` values count as supplied.
|
|
728
|
+
scope = {**params, **(element or {})}
|
|
729
|
+
self._rt._append_gsi_rederive_sets(
|
|
730
|
+
entity_name,
|
|
731
|
+
entity_name,
|
|
732
|
+
changes,
|
|
733
|
+
{
|
|
734
|
+
f: scope[f]
|
|
735
|
+
for f in _key_field_names(spec.get("keyCondition"))
|
|
736
|
+
if f in scope
|
|
737
|
+
},
|
|
738
|
+
names,
|
|
739
|
+
values,
|
|
740
|
+
sets,
|
|
741
|
+
)
|
|
742
|
+
|
|
720
743
|
update: Dict[str, Any] = {"TableName": table, "Key": key}
|
|
721
744
|
expr_parts: List[str] = []
|
|
722
745
|
if sets:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphddb-runtime"
|
|
7
|
-
version = "0.8.
|
|
7
|
+
version = "0.8.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"
|
|
@@ -145,6 +145,92 @@ def test_single_update_applies(rt, client):
|
|
|
145
145
|
assert _user_status(client, "z") == "disabled"
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
def test_single_update_rederives_gsi_from_literal_change(rt, client):
|
|
149
|
+
# #377: `setOrderStatusLiteral` carries its new status as a BAKED LITERAL
|
|
150
|
+
# ('running') inside `changes` — it is NOT a param. `status` composes the GSI1
|
|
151
|
+
# key, so the UpdateItem must re-derive GSI1PK onto the literal's partition.
|
|
152
|
+
# Building the GSI availability map from `params` alone counted the
|
|
153
|
+
# literal-changed field as "no value", so GSI1 was never re-derived from it
|
|
154
|
+
# and the write was refused as if `status` had not been supplied.
|
|
155
|
+
key = {"PK": {"S": "ORDER#o377"}, "SK": {"S": "META"}}
|
|
156
|
+
client.put_item(
|
|
157
|
+
TableName=TABLE,
|
|
158
|
+
Item={
|
|
159
|
+
**key,
|
|
160
|
+
"orderId": {"S": "o377"},
|
|
161
|
+
"tenantId": {"S": "t377"},
|
|
162
|
+
"status": {"S": "pending"},
|
|
163
|
+
"GSI1PK": {"S": "TENANT#t377#STATUS#pending"},
|
|
164
|
+
"GSI1SK": {"S": "ORDER#o377"},
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
rt.execute_command(
|
|
169
|
+
"OrderConfCommands__setOrderStatusLiteral",
|
|
170
|
+
{"orderId": "o377", "tenantId": "t377"},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
item = client.get_item(TableName=TABLE, Key=key)["Item"]
|
|
174
|
+
assert item["status"]["S"] == "running"
|
|
175
|
+
# The GSI key followed the row onto the literal's partition.
|
|
176
|
+
assert item["GSI1PK"]["S"] == "TENANT#t377#STATUS#running"
|
|
177
|
+
assert item["GSI1SK"]["S"] == "ORDER#o377"
|
|
178
|
+
|
|
179
|
+
# …and the index really resolves the row under the NEW partition, while the
|
|
180
|
+
# stale partition no longer does (the index did not rot).
|
|
181
|
+
def _by_gsi(pk):
|
|
182
|
+
return client.query(
|
|
183
|
+
TableName=TABLE,
|
|
184
|
+
IndexName="GSI1",
|
|
185
|
+
KeyConditionExpression="GSI1PK = :pk",
|
|
186
|
+
ExpressionAttributeValues={":pk": {"S": pk}},
|
|
187
|
+
)["Items"]
|
|
188
|
+
|
|
189
|
+
assert [i["orderId"]["S"] for i in _by_gsi("TENANT#t377#STATUS#running")] == ["o377"]
|
|
190
|
+
assert _by_gsi("TENANT#t377#STATUS#pending") == []
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_transacted_update_rederives_gsi_from_literal_change(rt, client):
|
|
194
|
+
# #377, the reporter's actual path: a multi-write body compiles to
|
|
195
|
+
# TransactWriteItems, and the transaction expanders derived GSI keys for `Put`
|
|
196
|
+
# only — so a transacted update committed a row whose GSI1PK still named the
|
|
197
|
+
# OLD partition, silently and with no error, while the identical single-key
|
|
198
|
+
# write refreshed it.
|
|
199
|
+
key = {"PK": {"S": "ORDER#o377tx"}, "SK": {"S": "META"}}
|
|
200
|
+
client.put_item(
|
|
201
|
+
TableName=TABLE,
|
|
202
|
+
Item={
|
|
203
|
+
**key,
|
|
204
|
+
"orderId": {"S": "o377tx"},
|
|
205
|
+
"tenantId": {"S": "t377tx"},
|
|
206
|
+
"status": {"S": "pending"},
|
|
207
|
+
"GSI1PK": {"S": "TENANT#t377tx#STATUS#pending"},
|
|
208
|
+
"GSI1SK": {"S": "ORDER#o377tx"},
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
rt.execute_transaction(
|
|
213
|
+
"OrderConfCommands__setOrderStatusLiteral__batch",
|
|
214
|
+
{"tenantId": "t377tx", "keys": [{"orderId": "o377tx"}]},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
item = client.get_item(TableName=TABLE, Key=key)["Item"]
|
|
218
|
+
assert item["status"]["S"] == "running"
|
|
219
|
+
assert item["GSI1PK"]["S"] == "TENANT#t377tx#STATUS#running"
|
|
220
|
+
assert item["GSI1SK"]["S"] == "ORDER#o377tx"
|
|
221
|
+
|
|
222
|
+
def _by_gsi(pk):
|
|
223
|
+
return client.query(
|
|
224
|
+
TableName=TABLE,
|
|
225
|
+
IndexName="GSI1",
|
|
226
|
+
KeyConditionExpression="GSI1PK = :pk",
|
|
227
|
+
ExpressionAttributeValues={":pk": {"S": pk}},
|
|
228
|
+
)["Items"]
|
|
229
|
+
|
|
230
|
+
assert [i["orderId"]["S"] for i in _by_gsi("TENANT#t377tx#STATUS#running")] == ["o377tx"]
|
|
231
|
+
assert _by_gsi("TENANT#t377tx#STATUS#pending") == []
|
|
232
|
+
|
|
233
|
+
|
|
148
234
|
def test_single_put_then_delete(rt, client):
|
|
149
235
|
rt.execute_command_method(
|
|
150
236
|
"MembershipCommands",
|
|
@@ -257,6 +257,156 @@ def test_update_item_rederives_affected_gsi_key():
|
|
|
257
257
|
assert values[sk_val_alias] == "ORDER#o1"
|
|
258
258
|
|
|
259
259
|
|
|
260
|
+
def test_update_item_rederives_affected_gsi_key_from_literal_change():
|
|
261
|
+
# #377: setOrderStatusLiteral bakes `status` as the LITERAL 'running' — the new
|
|
262
|
+
# value rides in `changes` and is NOT a param. `status` composes GSI1, so the
|
|
263
|
+
# update MUST still re-derive GSI1PK/GSI1SK from that literal. Building the
|
|
264
|
+
# availability map from `params` alone counted the literal-changed field as
|
|
265
|
+
# "no value", so GSI1 was never re-derived from it and the write was refused
|
|
266
|
+
# outright — demanding a `status` the command already supplies.
|
|
267
|
+
client = FakeClient()
|
|
268
|
+
rt = make_runtime(client)
|
|
269
|
+
rt.execute_command(
|
|
270
|
+
"OrderConfCommands__setOrderStatusLiteral",
|
|
271
|
+
{"orderId": "o1", "tenantId": "t1"},
|
|
272
|
+
)
|
|
273
|
+
method, req = client.calls[0]
|
|
274
|
+
assert method == "update_item"
|
|
275
|
+
names = req["ExpressionAttributeNames"]
|
|
276
|
+
values = plain(req["ExpressionAttributeValues"])
|
|
277
|
+
expr = req["UpdateExpression"]
|
|
278
|
+
gsi_pk_alias = next(a for a, n in names.items() if n == "GSI1PK")
|
|
279
|
+
gsi_sk_alias = next(a for a, n in names.items() if n == "GSI1SK")
|
|
280
|
+
pk_val_alias = expr.split(f"{gsi_pk_alias} = ")[1].split()[0].rstrip(",")
|
|
281
|
+
sk_val_alias = expr.split(f"{gsi_sk_alias} = ")[1].split()[0].rstrip(",")
|
|
282
|
+
# The GSI partition key carries the LITERAL new status, not a stale/empty one.
|
|
283
|
+
assert values[pk_val_alias] == "TENANT#t1#STATUS#running"
|
|
284
|
+
assert values[sk_val_alias] == "ORDER#o1"
|
|
285
|
+
# …and the literal is what the row itself is set to.
|
|
286
|
+
status_alias = next(a for a, n in names.items() if n == "status")
|
|
287
|
+
status_val_alias = expr.split(f"{status_alias} = ")[1].split()[0].rstrip(",")
|
|
288
|
+
assert values[status_val_alias] == "running"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_update_item_gsi_prefers_literal_change_over_same_named_param():
|
|
292
|
+
# #377, the genuinely SILENT shape: a GSI-composing field is set by a LITERAL
|
|
293
|
+
# change while a param of the SAME name carries a different (stale) value — as a
|
|
294
|
+
# W1 `write.before` hook's injected change produces. There is nothing "missing",
|
|
295
|
+
# so no error is raised; the availability map just had to choose. Building it
|
|
296
|
+
# from `params` alone chose the STALE param, quietly writing GSI1PK
|
|
297
|
+
# =TENANT#t1#STATUS#STALE while the row's status became 'running' — the index
|
|
298
|
+
# pointed at a partition the row was no longer in. The change must win.
|
|
299
|
+
client = FakeClient()
|
|
300
|
+
rt = make_runtime(client)
|
|
301
|
+
rt._commands["OrderConfCommands__setOrderStatusStale"] = {
|
|
302
|
+
"type": "UpdateItem",
|
|
303
|
+
"tableName": "UserPermissions",
|
|
304
|
+
"entity": "OrderModel",
|
|
305
|
+
"params": {
|
|
306
|
+
"orderId": {"type": "string", "required": True},
|
|
307
|
+
"tenantId": {"type": "string", "required": True},
|
|
308
|
+
"status": {"type": "string", "required": True},
|
|
309
|
+
},
|
|
310
|
+
"keyCondition": {"PK": "ORDER#{orderId}", "SK": "META"},
|
|
311
|
+
"changes": {"status": "running", "tenantId": "{tenantId}"},
|
|
312
|
+
}
|
|
313
|
+
rt.execute_command(
|
|
314
|
+
"OrderConfCommands__setOrderStatusStale",
|
|
315
|
+
{"orderId": "o1", "tenantId": "t1", "status": "STALE"},
|
|
316
|
+
)
|
|
317
|
+
_method, req = client.calls[0]
|
|
318
|
+
names = req["ExpressionAttributeNames"]
|
|
319
|
+
values = plain(req["ExpressionAttributeValues"])
|
|
320
|
+
expr = req["UpdateExpression"]
|
|
321
|
+
gsi_pk_alias = next(a for a, n in names.items() if n == "GSI1PK")
|
|
322
|
+
pk_val_alias = expr.split(f"{gsi_pk_alias} = ")[1].split()[0].rstrip(",")
|
|
323
|
+
assert values[pk_val_alias] == "TENANT#t1#STATUS#running"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_update_item_rederives_sort_key_and_every_affected_gsi_from_a_literal():
|
|
327
|
+
# #377: a literal change must re-derive an index's SORT key too, and an entity
|
|
328
|
+
# with SEVERAL indexes must re-derive every affected one (the shipped fixture
|
|
329
|
+
# entities all have exactly one GSI, so neither the `<index>SK`-from-literal
|
|
330
|
+
# path nor the per-index alias numbering was covered). Here `status` composes
|
|
331
|
+
# GSI1's PK and GSI2's SK, so one literal change moves both.
|
|
332
|
+
client = FakeClient()
|
|
333
|
+
rt = make_runtime(client)
|
|
334
|
+
rt._entities["TwoIndexOrderModel"] = {
|
|
335
|
+
"gsis": [
|
|
336
|
+
{
|
|
337
|
+
"indexName": "GSI1",
|
|
338
|
+
"inputFields": ["tenantId", "status", "orderId"],
|
|
339
|
+
"pkTemplate": "TENANT#{tenantId}#STATUS#{status}",
|
|
340
|
+
"skTemplate": "ORDER#{orderId}",
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"indexName": "GSI2",
|
|
344
|
+
"inputFields": ["tenantId", "status"],
|
|
345
|
+
"pkTemplate": "TENANT#{tenantId}",
|
|
346
|
+
"skTemplate": "STATUS#{status}",
|
|
347
|
+
},
|
|
348
|
+
]
|
|
349
|
+
}
|
|
350
|
+
rt._commands["TwoIndexOrder__setStatusLiteral"] = {
|
|
351
|
+
"type": "UpdateItem",
|
|
352
|
+
"tableName": "UserPermissions",
|
|
353
|
+
"entity": "TwoIndexOrderModel",
|
|
354
|
+
"params": {
|
|
355
|
+
"orderId": {"type": "string", "required": True},
|
|
356
|
+
"tenantId": {"type": "string", "required": True},
|
|
357
|
+
},
|
|
358
|
+
"keyCondition": {"PK": "ORDER#{orderId}", "SK": "META"},
|
|
359
|
+
"changes": {"status": "running", "tenantId": "{tenantId}"},
|
|
360
|
+
}
|
|
361
|
+
rt.execute_command(
|
|
362
|
+
"TwoIndexOrder__setStatusLiteral", {"orderId": "o1", "tenantId": "t1"}
|
|
363
|
+
)
|
|
364
|
+
_method, req = client.calls[0]
|
|
365
|
+
names = req["ExpressionAttributeNames"]
|
|
366
|
+
values = plain(req["ExpressionAttributeValues"])
|
|
367
|
+
expr = req["UpdateExpression"]
|
|
368
|
+
|
|
369
|
+
def value_of(attr):
|
|
370
|
+
alias = next(a for a, n in names.items() if n == attr)
|
|
371
|
+
return values[expr.split(f"{alias} = ")[1].split()[0].rstrip(",")]
|
|
372
|
+
|
|
373
|
+
assert value_of("GSI1PK") == "TENANT#t1#STATUS#running"
|
|
374
|
+
assert value_of("GSI1SK") == "ORDER#o1"
|
|
375
|
+
# The second index's SORT key is the one the literal moves.
|
|
376
|
+
assert value_of("GSI2PK") == "TENANT#t1"
|
|
377
|
+
assert value_of("GSI2SK") == "STATUS#running"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_update_item_gsi_ignores_params_the_row_does_not_carry():
|
|
381
|
+
# #377: the availability base is the fields the write KNOWS the row will carry —
|
|
382
|
+
# its key fields plus its changes — NOT the caller's whole param bag. A param
|
|
383
|
+
# that only feeds a write condition (here `tenantId`, neither a key field nor a
|
|
384
|
+
# change) says nothing about the row's contents, so composing GSI1PK from it
|
|
385
|
+
# would aim the index at a value the row does not have. Matches the TS
|
|
386
|
+
# `buildUpdateInput`, whose base is the update key's fields: throw instead.
|
|
387
|
+
from graphddb_runtime.errors import GraphDDBError
|
|
388
|
+
|
|
389
|
+
client = FakeClient()
|
|
390
|
+
rt = make_runtime(client)
|
|
391
|
+
rt._commands["OrderConfCommands__setOrderStatusCondOnly"] = {
|
|
392
|
+
"type": "UpdateItem",
|
|
393
|
+
"tableName": "UserPermissions",
|
|
394
|
+
"entity": "OrderModel",
|
|
395
|
+
"params": {
|
|
396
|
+
"orderId": {"type": "string", "required": True},
|
|
397
|
+
"tenantId": {"type": "string", "required": True},
|
|
398
|
+
},
|
|
399
|
+
"keyCondition": {"PK": "ORDER#{orderId}", "SK": "META"},
|
|
400
|
+
"changes": {"status": "running"},
|
|
401
|
+
}
|
|
402
|
+
with pytest.raises(GraphDDBError, match=r"affects index 'GSI1'.*depends on 'tenantId'"):
|
|
403
|
+
rt.execute_command(
|
|
404
|
+
"OrderConfCommands__setOrderStatusCondOnly",
|
|
405
|
+
{"orderId": "o1", "tenantId": "t1"},
|
|
406
|
+
)
|
|
407
|
+
assert client.calls == [] # nothing written
|
|
408
|
+
|
|
409
|
+
|
|
260
410
|
def test_update_item_throws_when_gsi_field_unavailable():
|
|
261
411
|
# #115: status alone affects GSI1 but tenantId is missing → explicit throw.
|
|
262
412
|
from graphddb_runtime.errors import GraphDDBError
|
|
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.8.1 → graphddb_runtime-0.8.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
|
{graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_binding_contract.py
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
|