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.
Files changed (50) hide show
  1. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/PKG-INFO +1 -1
  2. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/runtime.py +60 -20
  3. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/templates.py +25 -1
  4. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/transactions.py +23 -0
  5. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime.egg-info/PKG-INFO +1 -1
  6. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/pyproject.toml +1 -1
  7. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_command.py +86 -0
  8. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_unit.py +150 -0
  9. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/README.md +0 -0
  10. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/__init__.py +0 -0
  11. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/async_runtime.py +0 -0
  12. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/batch.py +0 -0
  13. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/concurrency.py +0 -0
  14. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/cursor.py +0 -0
  15. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/errors.py +0 -0
  16. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/filters.py +0 -0
  17. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/hydration.py +0 -0
  18. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/limits.py +0 -0
  19. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/middleware.py +0 -0
  20. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/per_key_cursor.py +0 -0
  21. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime/relations.py +0 -0
  22. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime.egg-info/SOURCES.txt +0 -0
  23. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
  24. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime.egg-info/requires.txt +0 -0
  25. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/graphddb_runtime.egg-info/top_level.txt +0 -0
  26. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/setup.cfg +0 -0
  27. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_concurrency.py +0 -0
  28. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_contract_runtime.py +0 -0
  29. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_crosslang_python_cells.py +0 -0
  30. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_description.py +0 -0
  31. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_description_followups.py +0 -0
  32. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_description_jsdoc.py +0 -0
  33. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_guard_eval.py +0 -0
  34. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration.py +0 -0
  35. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_binding_contract.py +0 -0
  36. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_compose.py +0 -0
  37. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_contract.py +0 -0
  38. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_edge_derive.py +0 -0
  39. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_edge_write.py +0 -0
  40. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_events.py +0 -0
  41. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_maintain.py +0 -0
  42. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_middleware.py +0 -0
  43. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_referential.py +0 -0
  44. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_relations.py +0 -0
  45. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_integration_unique.py +0 -0
  46. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_maintain.py +0 -0
  47. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_middleware.py +0 -0
  48. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_relations.py +0 -0
  49. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_spec_version_guard.py +0 -0
  50. {graphddb_runtime-0.8.1 → graphddb_runtime-0.8.3}/tests/test_ttl.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.8.1
3
+ Version: 0.8.3
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -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
- values[v] = self._serializer.serialize(resolve_template(tmpl, params))
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
- # silently rots — exactly as the TS `buildUpdateInput` does. The available
2410
- # values are the caller `params` (key fields change fields); a GSI is
2411
- # affected when at least one of its template placeholders is a changed
2412
- # field. An affected GSI whose composing fields are not all available
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, spec, params, names, values, sets
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
- command_id: str,
2441
- spec: Mapping[str, Any],
2442
- params: Mapping[str, Any],
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 the UpdateExpression (issue #115).
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 caller params (key fields
2452
- change fields). An affected GSI whose ``inputFields`` are not all available
2453
- throws — the SAME explicit error the TS path raises — rather than letting
2454
- the index silently rot.
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(spec.get("entity"), {})
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(spec.get("changes", {}).keys())
2482
+ changed = set(resolved_changes.keys())
2461
2483
  if not changed:
2462
2484
  return
2463
- # Available value map: caller params with a non-None value.
2464
- available = {k: v for k, v in params.items() if v is not None}
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"{command_id}: updating "
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.8.1
3
+ Version: 0.8.3
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphddb-runtime"
7
- version = "0.8.1"
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