graphddb-runtime 0.2.5__tar.gz → 0.3.0__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.5 → graphddb_runtime-0.3.0}/PKG-INFO +1 -1
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/middleware.py +3 -3
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/transactions.py +133 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/PKG-INFO +1 -1
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/SOURCES.txt +2 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/pyproject.toml +1 -1
- graphddb_runtime-0.3.0/tests/test_integration_maintain.py +337 -0
- graphddb_runtime-0.3.0/tests/test_maintain.py +280 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/README.md +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/__init__.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/async_runtime.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/batch.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/concurrency.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/cursor.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/errors.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/filters.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/hydration.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/limits.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/per_key_cursor.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/relations.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/runtime.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime/templates.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/requires.txt +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/graphddb_runtime.egg-info/top_level.txt +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/setup.cfg +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_concurrency.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_contract_runtime.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_command.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_compose.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_contract.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_edge_derive.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_edge_write.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_events.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_middleware.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_referential.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_relations.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_integration_unique.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_middleware.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_relations.py +0 -0
- {graphddb_runtime-0.2.5 → graphddb_runtime-0.3.0}/tests/test_unit.py +0 -0
|
@@ -25,14 +25,14 @@ Idiomatic Python differences from the TS reference (behavior is identical):
|
|
|
25
25
|
{...}}}``) OR a small object with the matching attributes — a ``Middleware``
|
|
26
26
|
is anything :func:`_hook` can read a callable off. This mirrors the TS object
|
|
27
27
|
literal a host registers, and keeps registration as light as ``runtime.use({...})``.
|
|
28
|
-
- Context objects are mutable dataclasses; the mutable fields
|
|
28
|
+
- Context objects are mutable dataclasses; the mutable fields docs/middleware.md marks
|
|
29
29
|
(``params`` in R1, ``operation`` in R2, ``kind`` / ``input`` in W1, ``items``
|
|
30
30
|
in W3) are plain attributes a hook reassigns or mutates in place.
|
|
31
31
|
|
|
32
32
|
A read or write issued with no ``context`` sees an empty ``{}`` — exactly as the
|
|
33
33
|
TS runtime threads ``context ?? {}``.
|
|
34
34
|
|
|
35
|
-
@see docs/
|
|
35
|
+
@see docs/middleware.md
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
38
|
from __future__ import annotations
|
|
@@ -51,7 +51,7 @@ from typing import (
|
|
|
51
51
|
|
|
52
52
|
# A registered middleware is any object/mapping carrying the hook groups. The
|
|
53
53
|
# library never validates its shape beyond reading callables off the known
|
|
54
|
-
# paths, mirroring the TS "hooks are unrestricted" stance (
|
|
54
|
+
# paths, mirroring the TS "hooks are unrestricted" stance (see docs/middleware.md).
|
|
55
55
|
Middleware = Any
|
|
56
56
|
RequestContext = Dict[str, Any]
|
|
57
57
|
Item = Dict[str, Any]
|
|
@@ -152,6 +152,53 @@ def _when_holds(
|
|
|
152
152
|
return left == right if when["op"] == "eq" else left != right
|
|
153
153
|
|
|
154
154
|
|
|
155
|
+
def _apply_maintain_transform(op: str, args: List[Any], value: Any) -> Any:
|
|
156
|
+
"""Apply one maintenance projection transform to a resolved source value (#129,
|
|
157
|
+
Epic #118) — the branch-for-branch mirror of the TS in-process
|
|
158
|
+
``applyProjectionTransform`` (``command-runtime.ts``) and the declarative
|
|
159
|
+
``applyMaintainTransform`` (``declarative-transaction.ts``):
|
|
160
|
+
|
|
161
|
+
- ``identity`` — copy the source value through unchanged;
|
|
162
|
+
- ``preview`` — keep the first ``n`` characters of the string form of the source
|
|
163
|
+
value (``args[0]`` is the positive-integer bound). ``None`` passes through.
|
|
164
|
+
|
|
165
|
+
Both runtimes apply this IDENTICALLY so the maintained row is byte-consistent.
|
|
166
|
+
"""
|
|
167
|
+
if op == "identity":
|
|
168
|
+
return value
|
|
169
|
+
if op == "preview":
|
|
170
|
+
n = args[0] if args else None
|
|
171
|
+
if not isinstance(n, int) or isinstance(n, bool) or n <= 0:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"a maintenance `preview` projection has a non-positive-integer length "
|
|
174
|
+
f"bound ({n!r})."
|
|
175
|
+
)
|
|
176
|
+
if value is None:
|
|
177
|
+
return value
|
|
178
|
+
return str(value)[:n]
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"unknown maintenance projection op '{op}' (expected 'identity' / 'preview')."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _build_maintain_projection(
|
|
185
|
+
maintain: Mapping[str, Any],
|
|
186
|
+
params: Mapping[str, Any],
|
|
187
|
+
element: Optional[Mapping[str, Any]],
|
|
188
|
+
) -> Dict[str, Any]:
|
|
189
|
+
"""Build the projected record a maintenance write mirrors onto the owner row: each
|
|
190
|
+
target attribute resolved from its source mutation-input field and run through its
|
|
191
|
+
transform. Mirrors the TS ``buildMaintainProjection``.
|
|
192
|
+
"""
|
|
193
|
+
out: Dict[str, Any] = {}
|
|
194
|
+
for attr, transform in maintain["projection"].items():
|
|
195
|
+
value = _resolve_value("{" + transform["inputField"] + "}", params, element)
|
|
196
|
+
out[attr] = _apply_maintain_transform(
|
|
197
|
+
transform["op"], transform.get("args", []), value
|
|
198
|
+
)
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
|
|
155
202
|
def _attr_str(value: Any) -> str:
|
|
156
203
|
"""The string form of a serialized DynamoDB attribute value (PK/SK are strings)."""
|
|
157
204
|
if isinstance(value, Mapping):
|
|
@@ -472,6 +519,18 @@ class TransactionExpander:
|
|
|
472
519
|
self._apply_condition(condition, check, params, element)
|
|
473
520
|
return {"ConditionCheck": check}
|
|
474
521
|
|
|
522
|
+
# #129 (Epic #118): a relation-side maintenance write — a projected `Update`
|
|
523
|
+
# on a SEPARATE owner row (a snapshot SET / a bounded-collection list_append),
|
|
524
|
+
# the branch-for-branch mirror of the TS in-process `renderMaintainWriteItem`
|
|
525
|
+
# and the declarative `buildMaintainUpdateItem`. The owner key was already
|
|
526
|
+
# resolved into `key`; the projection transform IR is applied here so the
|
|
527
|
+
# maintained row is byte-consistent across the runtimes.
|
|
528
|
+
maintain = spec.get("maintain")
|
|
529
|
+
if maintain is not None:
|
|
530
|
+
return self._build_maintain_update(
|
|
531
|
+
spec, maintain, table, key, params, element
|
|
532
|
+
)
|
|
533
|
+
|
|
475
534
|
# Update. Two distinct DynamoDB actions, both supported (issue #85):
|
|
476
535
|
# - `changes` → `SET #f = :v` (overwrite a named field);
|
|
477
536
|
# - `add` → `ADD #f :delta` (atomic numeric increment, concurrency-safe;
|
|
@@ -511,6 +570,80 @@ class TransactionExpander:
|
|
|
511
570
|
self._apply_condition(spec.get("condition"), update, params, element)
|
|
512
571
|
return {"Update": update}
|
|
513
572
|
|
|
573
|
+
def _build_maintain_update(
|
|
574
|
+
self,
|
|
575
|
+
spec: Mapping[str, Any],
|
|
576
|
+
maintain: Mapping[str, Any],
|
|
577
|
+
table: str,
|
|
578
|
+
key: Dict[str, Any],
|
|
579
|
+
params: Mapping[str, Any],
|
|
580
|
+
element: Optional[Mapping[str, Any]],
|
|
581
|
+
) -> Dict[str, Any]:
|
|
582
|
+
"""Build a relation-side maintenance ``Update`` (#129/#141, Epic #118): a
|
|
583
|
+
``counter`` applies an atomic scalar ``ADD #attr :delta``; a ``snapshot`` SETs
|
|
584
|
+
each projected attribute onto the owner row; a ``collection`` appends the
|
|
585
|
+
projected item into the bounded list
|
|
586
|
+
(``SET #c = list_append(if_not_exists(#c, :empty), :item)``). **Phase 1 is
|
|
587
|
+
append-only** — ``maxItems`` / ``orderBy`` are not trimmed synchronously
|
|
588
|
+
(#130). The branch-for-branch mirror of the TS ``renderMaintainWriteItem`` /
|
|
589
|
+
``buildMaintainUpdateItem``: the owner key is already resolved (``key``), and
|
|
590
|
+
the projection transform IR is applied so the maintained row is
|
|
591
|
+
byte-consistent across the runtimes.
|
|
592
|
+
"""
|
|
593
|
+
update: Dict[str, Any] = {"TableName": table, "Key": key}
|
|
594
|
+
kind = maintain["kind"]
|
|
595
|
+
# #141: a counter is a scalar atomic `ADD #attr :delta` (no projection) — the
|
|
596
|
+
# SAME `ADD #a0 :a0` shape the self-lifecycle derived counter (#85) builds, so a
|
|
597
|
+
# same-row counter ADD merges identically across runtimes.
|
|
598
|
+
if kind == "counter":
|
|
599
|
+
counter = maintain.get("counter")
|
|
600
|
+
if counter is None:
|
|
601
|
+
raise ValueError(
|
|
602
|
+
f"a 'counter' maintenance write on '{spec['entity']}' has no "
|
|
603
|
+
f"`counter` payload."
|
|
604
|
+
)
|
|
605
|
+
delta = counter["delta"]
|
|
606
|
+
delta = int(delta) if isinstance(delta, str) else delta
|
|
607
|
+
update["UpdateExpression"] = "ADD #a0 :a0"
|
|
608
|
+
update["ExpressionAttributeNames"] = {"#a0": counter["attribute"]}
|
|
609
|
+
update["ExpressionAttributeValues"] = {":a0": self._serializer.serialize(delta)}
|
|
610
|
+
return {"Update": update}
|
|
611
|
+
projection = _build_maintain_projection(maintain, params, element)
|
|
612
|
+
if kind == "collection":
|
|
613
|
+
collection = maintain.get("collection") or {}
|
|
614
|
+
field = collection.get("field")
|
|
615
|
+
if field is None:
|
|
616
|
+
raise ValueError(
|
|
617
|
+
f"a 'collection' maintenance write on '{spec['entity']}' has no "
|
|
618
|
+
f"`collection.field`."
|
|
619
|
+
)
|
|
620
|
+
update["UpdateExpression"] = (
|
|
621
|
+
"SET #c = list_append(if_not_exists(#c, :empty), :item)"
|
|
622
|
+
)
|
|
623
|
+
update["ExpressionAttributeNames"] = {"#c": field}
|
|
624
|
+
update["ExpressionAttributeValues"] = {
|
|
625
|
+
":empty": self._serializer.serialize([]),
|
|
626
|
+
":item": self._serializer.serialize([projection]),
|
|
627
|
+
}
|
|
628
|
+
else:
|
|
629
|
+
names: Dict[str, str] = {}
|
|
630
|
+
values: Dict[str, Any] = {}
|
|
631
|
+
sets: List[str] = []
|
|
632
|
+
for i, (attr, value) in enumerate(projection.items()):
|
|
633
|
+
names[f"#m{i}"] = attr
|
|
634
|
+
values[f":m{i}"] = self._serializer.serialize(value)
|
|
635
|
+
sets.append(f"#m{i} = :m{i}")
|
|
636
|
+
if not sets:
|
|
637
|
+
raise ValueError(
|
|
638
|
+
f"a 'snapshot' maintenance write on '{spec['entity']}' has an empty "
|
|
639
|
+
f"projection."
|
|
640
|
+
)
|
|
641
|
+
update["UpdateExpression"] = "SET " + ", ".join(sets)
|
|
642
|
+
update["ExpressionAttributeNames"] = names
|
|
643
|
+
update["ExpressionAttributeValues"] = values
|
|
644
|
+
# A maintenance write carries no DynamoDB write condition (it upserts the row).
|
|
645
|
+
return {"Update": update}
|
|
646
|
+
|
|
514
647
|
def expand(
|
|
515
648
|
self, spec: Mapping[str, Any], params: Mapping[str, Any]
|
|
516
649
|
) -> List[Dict[str, Any]]:
|
|
@@ -29,10 +29,12 @@ tests/test_integration_contract.py
|
|
|
29
29
|
tests/test_integration_edge_derive.py
|
|
30
30
|
tests/test_integration_edge_write.py
|
|
31
31
|
tests/test_integration_events.py
|
|
32
|
+
tests/test_integration_maintain.py
|
|
32
33
|
tests/test_integration_middleware.py
|
|
33
34
|
tests/test_integration_referential.py
|
|
34
35
|
tests/test_integration_relations.py
|
|
35
36
|
tests/test_integration_unique.py
|
|
37
|
+
tests/test_maintain.py
|
|
36
38
|
tests/test_middleware.py
|
|
37
39
|
tests/test_relations.py
|
|
38
40
|
tests/test_unit.py
|
|
@@ -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.3.0"
|
|
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"
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Integration tests for #129 relation-side MAINTENANCE WRITES against DynamoDB Local.
|
|
2
|
+
|
|
3
|
+
Exercises ``GraphDDBRuntime.execute_command_method`` on the maintain contracts
|
|
4
|
+
generated from the same SSoT the TS in-process runtime (#127) executes:
|
|
5
|
+
|
|
6
|
+
- COLLECTION (embeddedSnapshot / ``@hasMany``): ``ThreadPostCommands.create`` fires
|
|
7
|
+
``ThreadPost.created`` → ``ThreadSummary.latestPosts``. The create composes the
|
|
8
|
+
ThreadPost Put AND a projected ``list_append`` onto the THREADSUMMARY#<threadId>
|
|
9
|
+
owner row in ONE atomic ``TransactWriteItems`` — the owner row is UPSERTED and the
|
|
10
|
+
projected item (``postId`` identity, ``textPreview = preview(body, 120)``) appended.
|
|
11
|
+
- SNAPSHOT (``@belongsTo``): ``ProfileCommands.update`` fires ``Profile.updated`` →
|
|
12
|
+
``ProfileCard``. The update composes the Profile Put AND a projected ``SET``
|
|
13
|
+
(``displayName`` identity, ``bioPreview = preview(bio, 40)``) onto the
|
|
14
|
+
PROFILECARD#<userId> owner row, atomically.
|
|
15
|
+
|
|
16
|
+
ACs proven on real DynamoDB (mirroring __tests__/integration/mutation-maintain.test.ts
|
|
17
|
+
over the SAME SSoT, so the two runtimes produce identical effects / identical rollback):
|
|
18
|
+
|
|
19
|
+
- 1 atomic tx: after ONE create, BOTH the source row and the maintained append are
|
|
20
|
+
present; after ONE update, BOTH the source row and the snapshot mirror are present.
|
|
21
|
+
- append-only (Phase 1): a second create appends a SECOND item (the first preserved).
|
|
22
|
+
- atomic rollback: re-creating the SAME post fails the create guard and does NOT
|
|
23
|
+
append a duplicate maintained item (the maintenance write rolled back WITH the
|
|
24
|
+
source — it is not a separate, independently-applied write).
|
|
25
|
+
- read-back invariant: the mirrored values match the source payload through the
|
|
26
|
+
projection transform (identity / preview), byte-for-byte the TS runtime's output.
|
|
27
|
+
|
|
28
|
+
Marked ``integration`` so it is skipped by ``-m "not integration"``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
|
|
35
|
+
import boto3
|
|
36
|
+
import pytest
|
|
37
|
+
from botocore.config import Config
|
|
38
|
+
|
|
39
|
+
import conftest
|
|
40
|
+
from graphddb_runtime import GraphDDBRuntime
|
|
41
|
+
|
|
42
|
+
pytestmark = pytest.mark.integration
|
|
43
|
+
|
|
44
|
+
ENDPOINT = os.environ.get("DDB_LOCAL_ENDPOINT", "http://localhost:8000")
|
|
45
|
+
TABLE = "UserPermissions"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture(scope="module")
|
|
49
|
+
def client():
|
|
50
|
+
cfg = Config(retries={"max_attempts": 2}, connect_timeout=2, read_timeout=5)
|
|
51
|
+
c = boto3.client(
|
|
52
|
+
"dynamodb",
|
|
53
|
+
endpoint_url=ENDPOINT,
|
|
54
|
+
region_name="local",
|
|
55
|
+
aws_access_key_id="fake",
|
|
56
|
+
aws_secret_access_key="fake",
|
|
57
|
+
config=cfg,
|
|
58
|
+
)
|
|
59
|
+
try:
|
|
60
|
+
c.list_tables()
|
|
61
|
+
except Exception as exc: # pragma: no cover
|
|
62
|
+
pytest.skip(f"DynamoDB Local not reachable at {ENDPOINT}: {exc}")
|
|
63
|
+
yield c
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture(scope="module")
|
|
67
|
+
def table(client):
|
|
68
|
+
try:
|
|
69
|
+
client.delete_table(TableName=TABLE)
|
|
70
|
+
client.get_waiter("table_not_exists").wait(TableName=TABLE)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
client.create_table(
|
|
74
|
+
TableName=TABLE,
|
|
75
|
+
KeySchema=[
|
|
76
|
+
{"AttributeName": "PK", "KeyType": "HASH"},
|
|
77
|
+
{"AttributeName": "SK", "KeyType": "RANGE"},
|
|
78
|
+
],
|
|
79
|
+
AttributeDefinitions=[
|
|
80
|
+
{"AttributeName": "PK", "AttributeType": "S"},
|
|
81
|
+
{"AttributeName": "SK", "AttributeType": "S"},
|
|
82
|
+
{"AttributeName": "GSI1PK", "AttributeType": "S"},
|
|
83
|
+
{"AttributeName": "GSI1SK", "AttributeType": "S"},
|
|
84
|
+
],
|
|
85
|
+
GlobalSecondaryIndexes=[
|
|
86
|
+
{
|
|
87
|
+
"IndexName": "GSI1",
|
|
88
|
+
"KeySchema": [
|
|
89
|
+
{"AttributeName": "GSI1PK", "KeyType": "HASH"},
|
|
90
|
+
{"AttributeName": "GSI1SK", "KeyType": "RANGE"},
|
|
91
|
+
],
|
|
92
|
+
"Projection": {"ProjectionType": "ALL"},
|
|
93
|
+
}
|
|
94
|
+
],
|
|
95
|
+
BillingMode="PAY_PER_REQUEST",
|
|
96
|
+
)
|
|
97
|
+
client.get_waiter("table_exists").wait(TableName=TABLE)
|
|
98
|
+
yield TABLE
|
|
99
|
+
client.delete_table(TableName=TABLE)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.fixture()
|
|
103
|
+
def rt(client, table):
|
|
104
|
+
return GraphDDBRuntime(
|
|
105
|
+
dynamodb_client=client,
|
|
106
|
+
manifest_path=conftest.MANIFEST_PATH,
|
|
107
|
+
operations_path=conftest.OPERATIONS_PATH,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _row(client, pk, sk):
|
|
112
|
+
res = client.get_item(TableName=TABLE, Key={"PK": {"S": pk}, "SK": {"S": sk}})
|
|
113
|
+
return res.get("Item")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── COLLECTION maintainer: ThreadPost.created → ThreadSummary.latestPosts ─────────
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_collection_create_writes_post_and_appends_maintained_item_atomically(rt, client):
|
|
120
|
+
result = rt.execute_command_method(
|
|
121
|
+
"ThreadPostCommands",
|
|
122
|
+
"create",
|
|
123
|
+
{"threadId": "it1", "postId": "p1"},
|
|
124
|
+
{
|
|
125
|
+
"threadId": "it1",
|
|
126
|
+
"postId": "p1",
|
|
127
|
+
"body": "hello world",
|
|
128
|
+
"createdAt": "2026-06-01T00:00:00.000Z",
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
# The read-back projection of the source write.
|
|
132
|
+
assert result == {"threadId": "it1", "postId": "p1", "body": "hello world"}
|
|
133
|
+
|
|
134
|
+
# The ThreadPost row persisted (the base write).
|
|
135
|
+
post = _row(client, "THREAD#it1", "POST#p1")
|
|
136
|
+
assert post is not None and post["body"]["S"] == "hello world"
|
|
137
|
+
|
|
138
|
+
# The ThreadSummary owner row was UPSERTED and the maintained collection item
|
|
139
|
+
# appended — in the SAME transaction as the post Put.
|
|
140
|
+
summary = _row(client, "THREADSUMMARY#it1", "SUMMARY")
|
|
141
|
+
assert summary is not None
|
|
142
|
+
assert summary["latestPosts"] == {
|
|
143
|
+
"L": [{"M": {"postId": {"S": "p1"}, "textPreview": {"S": "hello world"}}}]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# #141: the SAME ThreadPost.created ALSO incremented the ThreadCounter.postCount
|
|
147
|
+
# counter (+1, on a DIFFERENT owner row) — upserted from absent in the same tx.
|
|
148
|
+
counter = _row(client, "THREADCOUNT#it1", "COUNT")
|
|
149
|
+
assert counter is not None and counter["postCount"]["N"] == "1"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_collection_second_create_appends_second_projected_item(rt, client):
|
|
153
|
+
long_body = "x" * 300
|
|
154
|
+
rt.execute_command_method(
|
|
155
|
+
"ThreadPostCommands",
|
|
156
|
+
"create",
|
|
157
|
+
{"threadId": "it1", "postId": "p2"},
|
|
158
|
+
{
|
|
159
|
+
"threadId": "it1",
|
|
160
|
+
"postId": "p2",
|
|
161
|
+
"body": long_body,
|
|
162
|
+
"createdAt": "2026-06-02T00:00:00.000Z",
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
summary = _row(client, "THREADSUMMARY#it1", "SUMMARY")
|
|
166
|
+
items = summary["latestPosts"]["L"]
|
|
167
|
+
assert len(items) == 2
|
|
168
|
+
# The first item is preserved (append, not overwrite).
|
|
169
|
+
assert items[0] == {"M": {"postId": {"S": "p1"}, "textPreview": {"S": "hello world"}}}
|
|
170
|
+
# The second item's textPreview is preview(body, 120) — exactly 120 chars.
|
|
171
|
+
assert items[1]["M"]["postId"]["S"] == "p2"
|
|
172
|
+
assert len(items[1]["M"]["textPreview"]["S"]) == 120
|
|
173
|
+
assert items[1]["M"]["textPreview"]["S"] == "x" * 120
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_collection_atomic_rollback_no_duplicate_append(rt, client):
|
|
177
|
+
# Before: latestPosts has 2 items.
|
|
178
|
+
before = _row(client, "THREADSUMMARY#it1", "SUMMARY")
|
|
179
|
+
assert len(before["latestPosts"]["L"]) == 2
|
|
180
|
+
|
|
181
|
+
# Re-create p1 — the entity Put's attribute_not_exists(PK) guard fails, cancelling
|
|
182
|
+
# the WHOLE TransactWriteItems (the source Put + the maintenance append).
|
|
183
|
+
with pytest.raises(Exception):
|
|
184
|
+
rt.execute_command_method(
|
|
185
|
+
"ThreadPostCommands",
|
|
186
|
+
"create",
|
|
187
|
+
{"threadId": "it1", "postId": "p1"},
|
|
188
|
+
{
|
|
189
|
+
"threadId": "it1",
|
|
190
|
+
"postId": "p1",
|
|
191
|
+
"body": "duplicate",
|
|
192
|
+
"createdAt": "2026-06-03T00:00:00.000Z",
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
# Proof of atomic rollback: the maintained collection was NOT appended a third time
|
|
196
|
+
# — still exactly the 2 prior items (the maintenance write rolled back WITH the
|
|
197
|
+
# failed source write; it is not a separate, independently-applied write).
|
|
198
|
+
after = _row(client, "THREADSUMMARY#it1", "SUMMARY")
|
|
199
|
+
assert len(after["latestPosts"]["L"]) == 2
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── SNAPSHOT maintainer: Profile.updated → ProfileCard mirror ─────────────────────
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_snapshot_update_mirrors_projected_row_into_owner_atomically(rt, client):
|
|
206
|
+
# Seed the Profile so the `update` has a row to write (and the snapshot has a source).
|
|
207
|
+
client.put_item(
|
|
208
|
+
TableName=TABLE,
|
|
209
|
+
Item={
|
|
210
|
+
"PK": {"S": "PROFILE#u1"},
|
|
211
|
+
"SK": {"S": "META"},
|
|
212
|
+
"userId": {"S": "u1"},
|
|
213
|
+
"displayName": {"S": "old name"},
|
|
214
|
+
"bio": {"S": "old bio"},
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
bio = (
|
|
218
|
+
"mathematician and the first to recognise that the analytical engine "
|
|
219
|
+
"had applications beyond pure calculation"
|
|
220
|
+
)
|
|
221
|
+
result = rt.execute_command_method(
|
|
222
|
+
"ProfileCommands",
|
|
223
|
+
"update",
|
|
224
|
+
{"userId": "u1"},
|
|
225
|
+
{"userId": "u1", "displayName": "Ada Lovelace", "bio": bio},
|
|
226
|
+
)
|
|
227
|
+
assert result == {"userId": "u1", "displayName": "Ada Lovelace"}
|
|
228
|
+
|
|
229
|
+
# The Profile row updated (base write).
|
|
230
|
+
profile = _row(client, "PROFILE#u1", "META")
|
|
231
|
+
assert profile["displayName"]["S"] == "Ada Lovelace"
|
|
232
|
+
|
|
233
|
+
# The ProfileCard owner row was UPSERTED with the projected snapshot — same tx.
|
|
234
|
+
card = _row(client, "PROFILECARD#u1", "CARD")
|
|
235
|
+
assert card is not None
|
|
236
|
+
assert card["displayName"]["S"] == "Ada Lovelace"
|
|
237
|
+
# bioPreview = preview(bio, 40) — the first 40 characters.
|
|
238
|
+
assert card["bioPreview"]["S"] == bio[:40]
|
|
239
|
+
assert len(card["bioPreview"]["S"]) == 40
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ── #141 COUNTER maintainer: ThreadPost.created/removed → ThreadCounter.postCount ──
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_counter_create_increments_postcount_plus_one_atomically(rt, client):
|
|
246
|
+
"""`ThreadPost.created` → ThreadCounter.postCount += 1: the create composes the
|
|
247
|
+
ThreadPost Put AND an atomic `ADD postCount 1` onto the THREADCOUNT#<threadId> owner
|
|
248
|
+
row in ONE atomic transact_write_items (upserted from absent)."""
|
|
249
|
+
rt.execute_command_method(
|
|
250
|
+
"ThreadPostCommands",
|
|
251
|
+
"create",
|
|
252
|
+
{"threadId": "ctC", "postId": "cp1"},
|
|
253
|
+
{
|
|
254
|
+
"threadId": "ctC",
|
|
255
|
+
"postId": "cp1",
|
|
256
|
+
"body": "first",
|
|
257
|
+
"createdAt": "2026-06-01T00:00:00.000Z",
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
assert _row(client, "THREAD#ctC", "POST#cp1") is not None
|
|
261
|
+
counter = _row(client, "THREADCOUNT#ctC", "COUNT")
|
|
262
|
+
assert counter is not None and counter["postCount"]["N"] == "1"
|
|
263
|
+
|
|
264
|
+
# A second create increments to 2 (concurrency-safe ADD, no read-modify-write).
|
|
265
|
+
rt.execute_command_method(
|
|
266
|
+
"ThreadPostCommands",
|
|
267
|
+
"create",
|
|
268
|
+
{"threadId": "ctC", "postId": "cp2"},
|
|
269
|
+
{
|
|
270
|
+
"threadId": "ctC",
|
|
271
|
+
"postId": "cp2",
|
|
272
|
+
"body": "second",
|
|
273
|
+
"createdAt": "2026-06-02T00:00:00.000Z",
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
counter = _row(client, "THREADCOUNT#ctC", "COUNT")
|
|
277
|
+
assert counter["postCount"]["N"] == "2"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_counter_remove_decrements_postcount_minus_one_atomically(rt, client):
|
|
281
|
+
"""`ThreadPost.removed` → ThreadCounter.postCount -= 1: the remove composes the
|
|
282
|
+
ThreadPost Delete AND an atomic `ADD postCount -1` in ONE atomic tx."""
|
|
283
|
+
# Seed a counter at 2 and a ThreadPost row to remove.
|
|
284
|
+
client.put_item(
|
|
285
|
+
TableName=TABLE,
|
|
286
|
+
Item={
|
|
287
|
+
"PK": {"S": "THREADCOUNT#ctR"},
|
|
288
|
+
"SK": {"S": "COUNT"},
|
|
289
|
+
"threadId": {"S": "ctR"},
|
|
290
|
+
"postCount": {"N": "2"},
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
client.put_item(
|
|
294
|
+
TableName=TABLE,
|
|
295
|
+
Item={
|
|
296
|
+
"PK": {"S": "THREAD#ctR"},
|
|
297
|
+
"SK": {"S": "POST#cpR"},
|
|
298
|
+
"threadId": {"S": "ctR"},
|
|
299
|
+
"postId": {"S": "cpR"},
|
|
300
|
+
"body": {"S": "to remove"},
|
|
301
|
+
"createdAt": {"S": "2026-06-02T00:00:00.000Z"},
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
rt.execute_command_method(
|
|
305
|
+
"ThreadPostCommands",
|
|
306
|
+
"remove",
|
|
307
|
+
{"threadId": "ctR", "postId": "cpR"},
|
|
308
|
+
{"threadId": "ctR", "postId": "cpR"},
|
|
309
|
+
)
|
|
310
|
+
# The base post row removed.
|
|
311
|
+
assert _row(client, "THREAD#ctR", "POST#cpR") is None
|
|
312
|
+
# The counter decremented (2 → 1) by the atomic ADD(-1) in the same tx.
|
|
313
|
+
counter = _row(client, "THREADCOUNT#ctR", "COUNT")
|
|
314
|
+
assert counter["postCount"]["N"] == "1"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_counter_atomic_rollback_no_double_increment(rt, client):
|
|
318
|
+
"""Re-creating the same post fails the create guard and the WHOLE tx rolls back —
|
|
319
|
+
the counter is NOT double-incremented (the ADD rode the failed source Put)."""
|
|
320
|
+
rt.execute_command_method(
|
|
321
|
+
"ThreadPostCommands",
|
|
322
|
+
"create",
|
|
323
|
+
{"threadId": "ctX", "postId": "cpX"},
|
|
324
|
+
{"threadId": "ctX", "postId": "cpX", "body": "one", "createdAt": "2026-06-01T00:00:00.000Z"},
|
|
325
|
+
)
|
|
326
|
+
before = _row(client, "THREADCOUNT#ctX", "COUNT")
|
|
327
|
+
assert before["postCount"]["N"] == "1"
|
|
328
|
+
|
|
329
|
+
with pytest.raises(Exception):
|
|
330
|
+
rt.execute_command_method(
|
|
331
|
+
"ThreadPostCommands",
|
|
332
|
+
"create",
|
|
333
|
+
{"threadId": "ctX", "postId": "cpX"},
|
|
334
|
+
{"threadId": "ctX", "postId": "cpX", "body": "dup", "createdAt": "2026-06-02T00:00:00.000Z"},
|
|
335
|
+
)
|
|
336
|
+
after = _row(client, "THREADCOUNT#ctX", "COUNT")
|
|
337
|
+
assert after["postCount"]["N"] == "1"
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Unit tests for relation-side MAINTENANCE WRITES (#129, Epic #118).
|
|
2
|
+
|
|
3
|
+
The Python mirror of the TS in-process `renderMaintainWriteItem`
|
|
4
|
+
(`src/runtime/command-runtime.ts`) and the declarative `buildMaintainUpdateItem`
|
|
5
|
+
(`src/operations/declarative-transaction.ts`). These prove the Python runtime
|
|
6
|
+
realizes the serialized maintenance shape — the projection transform IR
|
|
7
|
+
(`identity` / `preview`) + the bounded-collection append — IDENTICALLY, the same
|
|
8
|
+
way the conformance golden proves it end-to-end against DynamoDB Local.
|
|
9
|
+
|
|
10
|
+
No network: the maintain command is driven through `execute_command_method`
|
|
11
|
+
against a recording fake client that captures the `transact_write_items` request,
|
|
12
|
+
so the projected `Update` item (the snapshot `SET` / the collection `list_append`)
|
|
13
|
+
is asserted directly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
from boto3.dynamodb.types import TypeDeserializer
|
|
20
|
+
|
|
21
|
+
from graphddb_runtime import GraphDDBRuntime
|
|
22
|
+
from graphddb_runtime.transactions import (
|
|
23
|
+
_apply_maintain_transform,
|
|
24
|
+
_build_maintain_projection,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
import conftest
|
|
28
|
+
|
|
29
|
+
_DESER = TypeDeserializer()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def make_runtime(client, **kw) -> GraphDDBRuntime:
|
|
33
|
+
return GraphDDBRuntime(
|
|
34
|
+
dynamodb_client=client,
|
|
35
|
+
manifest_path=conftest.MANIFEST_PATH,
|
|
36
|
+
operations_path=conftest.OPERATIONS_PATH,
|
|
37
|
+
**kw,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def plain(av_map):
|
|
42
|
+
return {k: _DESER.deserialize(v) for k, v in av_map.items()}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FakeClient:
|
|
46
|
+
def __init__(self):
|
|
47
|
+
self.calls = []
|
|
48
|
+
|
|
49
|
+
def transact_write_items(self, **kwargs):
|
|
50
|
+
self.calls.append(("transact_write_items", kwargs))
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
# The maintain commands declare a `result.select`, so a consistent read-back
|
|
54
|
+
# GetItem follows the write; return an empty item (the read-back parity is
|
|
55
|
+
# covered by conformance, not this unit test).
|
|
56
|
+
def get_item(self, **kwargs):
|
|
57
|
+
self.calls.append(("get_item", kwargs))
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── the transform IR mirror (applyProjectionTransform parity) ────────────────────
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_identity_transform_copies_value_through():
|
|
65
|
+
assert _apply_maintain_transform("identity", [], "hello") == "hello"
|
|
66
|
+
assert _apply_maintain_transform("identity", [], 42) == 42
|
|
67
|
+
assert _apply_maintain_transform("identity", [], None) is None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_preview_transform_keeps_first_n_chars():
|
|
71
|
+
# Exactly the TS `applyProjectionTransform('preview', [n], value)` semantics:
|
|
72
|
+
# the first n characters of the string form; a short value is unchanged.
|
|
73
|
+
assert _apply_maintain_transform("preview", [5], "hello world") == "hello"
|
|
74
|
+
assert _apply_maintain_transform("preview", [120], "short") == "short"
|
|
75
|
+
# A non-string is stringified first (matching `String(value).slice(0, n)`).
|
|
76
|
+
assert _apply_maintain_transform("preview", [2], 12345) == "12"
|
|
77
|
+
# None passes through (a missing source value is not previewed).
|
|
78
|
+
assert _apply_maintain_transform("preview", [10], None) is None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_preview_transform_rejects_non_positive_bound():
|
|
82
|
+
for bad in (0, -1, 1.5, "x", None):
|
|
83
|
+
with pytest.raises(ValueError):
|
|
84
|
+
_apply_maintain_transform("preview", [bad], "value")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_unknown_transform_op_rejects_loudly():
|
|
88
|
+
with pytest.raises(ValueError):
|
|
89
|
+
_apply_maintain_transform("uppercase", [], "value")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_build_maintain_projection_applies_each_transform():
|
|
93
|
+
maintain = {
|
|
94
|
+
"projection": {
|
|
95
|
+
"postId": {"op": "identity", "args": [], "inputField": "postId"},
|
|
96
|
+
"textPreview": {"op": "preview", "args": [5], "inputField": "body"},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
out = _build_maintain_projection(
|
|
100
|
+
maintain, {"postId": "p1", "body": "hello world"}, None
|
|
101
|
+
)
|
|
102
|
+
assert out == {"postId": "p1", "textPreview": "hello"}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── the collection maintainer (embeddedSnapshot / hasMany) ───────────────────────
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_collection_maintainer_appends_projected_item_via_list_append():
|
|
109
|
+
"""`ThreadPost.created` → ThreadSummary.latestPosts: the create composes the
|
|
110
|
+
ThreadPost Put AND a `list_append` onto the THREADSUMMARY#<threadId> owner row in
|
|
111
|
+
ONE atomic transact_write_items — the Python mirror of the TS in-process append."""
|
|
112
|
+
client = FakeClient()
|
|
113
|
+
rt = make_runtime(client)
|
|
114
|
+
rt.execute_command_method(
|
|
115
|
+
"ThreadPostCommands",
|
|
116
|
+
"create",
|
|
117
|
+
{"threadId": "t1", "postId": "p1"},
|
|
118
|
+
{
|
|
119
|
+
"threadId": "t1",
|
|
120
|
+
"postId": "p1",
|
|
121
|
+
"body": "hello world",
|
|
122
|
+
"createdAt": "2026-06-01T00:00:00.000Z",
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
write_calls = [c for c in client.calls if c[0] == "transact_write_items"]
|
|
126
|
+
assert len(write_calls) == 1
|
|
127
|
+
items = write_calls[0][1]["TransactItems"]
|
|
128
|
+
# The base ThreadPost Put + the collection maintenance Update on the THREADSUMMARY
|
|
129
|
+
# owner row + (#141) the counter ADD on the THREADCOUNT owner row.
|
|
130
|
+
puts = [it["Put"] for it in items if "Put" in it]
|
|
131
|
+
updates = [it["Update"] for it in items if "Update" in it]
|
|
132
|
+
assert len(puts) == 1
|
|
133
|
+
assert plain(puts[0]["Item"])["PK"] == "THREAD#t1"
|
|
134
|
+
|
|
135
|
+
# Select the collection maintainer by its owner-row key (the counter Update targets
|
|
136
|
+
# a DIFFERENT row, THREADCOUNT#t1, so the two never collide).
|
|
137
|
+
upd = next(u for u in updates if plain(u["Key"])["PK"] == "THREADSUMMARY#t1")
|
|
138
|
+
assert plain(upd["Key"]) == {"PK": "THREADSUMMARY#t1", "SK": "SUMMARY"}
|
|
139
|
+
# Append-only: list_append(if_not_exists(#c, :empty), :item) onto `latestPosts`.
|
|
140
|
+
assert (
|
|
141
|
+
upd["UpdateExpression"]
|
|
142
|
+
== "SET #c = list_append(if_not_exists(#c, :empty), :item)"
|
|
143
|
+
)
|
|
144
|
+
assert upd["ExpressionAttributeNames"] == {"#c": "latestPosts"}
|
|
145
|
+
vals = upd["ExpressionAttributeValues"]
|
|
146
|
+
assert _DESER.deserialize(vals[":empty"]) == []
|
|
147
|
+
# The appended item: postId identity, textPreview = preview(body, 120).
|
|
148
|
+
appended = _DESER.deserialize(vals[":item"])
|
|
149
|
+
assert appended == [{"postId": "p1", "textPreview": "hello world"}]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_collection_maintainer_preview_truncates_long_body_to_120():
|
|
153
|
+
client = FakeClient()
|
|
154
|
+
rt = make_runtime(client)
|
|
155
|
+
long_body = "x" * 300
|
|
156
|
+
rt.execute_command_method(
|
|
157
|
+
"ThreadPostCommands",
|
|
158
|
+
"create",
|
|
159
|
+
{"threadId": "t2", "postId": "p2"},
|
|
160
|
+
{
|
|
161
|
+
"threadId": "t2",
|
|
162
|
+
"postId": "p2",
|
|
163
|
+
"body": long_body,
|
|
164
|
+
"createdAt": "2026-06-02T00:00:00.000Z",
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
items = [c for c in client.calls if c[0] == "transact_write_items"][0][1][
|
|
168
|
+
"TransactItems"
|
|
169
|
+
]
|
|
170
|
+
# Select the collection maintainer (THREADSUMMARY) — the #141 counter Update targets
|
|
171
|
+
# THREADCOUNT and carries no `:item`.
|
|
172
|
+
updates = [it["Update"] for it in items if "Update" in it]
|
|
173
|
+
upd = next(u for u in updates if plain(u["Key"])["PK"] == "THREADSUMMARY#t2")
|
|
174
|
+
appended = _DESER.deserialize(upd["ExpressionAttributeValues"][":item"])
|
|
175
|
+
assert len(appended[0]["textPreview"]) == 120
|
|
176
|
+
assert appended[0]["textPreview"] == "x" * 120
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── the snapshot maintainer (belongsTo) ──────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_snapshot_maintainer_sets_projected_attributes_onto_owner_row():
|
|
183
|
+
"""`Profile.updated` → ProfileCard snapshot: the update composes the Profile Put
|
|
184
|
+
AND a `SET` of each projected attribute onto the PROFILECARD#<userId> owner row in
|
|
185
|
+
ONE atomic transact_write_items — the Python mirror of the TS in-process snapshot."""
|
|
186
|
+
client = FakeClient()
|
|
187
|
+
rt = make_runtime(client)
|
|
188
|
+
bio = (
|
|
189
|
+
"mathematician and the first to recognise that the analytical engine "
|
|
190
|
+
"had applications beyond pure calculation"
|
|
191
|
+
)
|
|
192
|
+
rt.execute_command_method(
|
|
193
|
+
"ProfileCommands",
|
|
194
|
+
"update",
|
|
195
|
+
{"userId": "u1"},
|
|
196
|
+
{"userId": "u1", "displayName": "Ada Lovelace", "bio": bio},
|
|
197
|
+
)
|
|
198
|
+
items = [c for c in client.calls if c[0] == "transact_write_items"][0][1][
|
|
199
|
+
"TransactItems"
|
|
200
|
+
]
|
|
201
|
+
updates = [it["Update"] for it in items if "Update" in it]
|
|
202
|
+
# The base Profile Update (the `update` write) + the maintenance Update on the card.
|
|
203
|
+
card = next(
|
|
204
|
+
u for u in updates if plain(u["Key"])["PK"] == "PROFILECARD#u1"
|
|
205
|
+
)
|
|
206
|
+
assert plain(card["Key"]) == {"PK": "PROFILECARD#u1", "SK": "CARD"}
|
|
207
|
+
assert card["UpdateExpression"].startswith("SET ")
|
|
208
|
+
names = card["ExpressionAttributeNames"]
|
|
209
|
+
values = {k: _DESER.deserialize(v) for k, v in card["ExpressionAttributeValues"].items()}
|
|
210
|
+
# Reconstruct attr -> value from the #mN / :mN aliases.
|
|
211
|
+
projected = {}
|
|
212
|
+
for name_alias, attr in names.items():
|
|
213
|
+
value_alias = ":" + name_alias[1:]
|
|
214
|
+
projected[attr] = values[value_alias]
|
|
215
|
+
assert projected["displayName"] == "Ada Lovelace"
|
|
216
|
+
# bioPreview = preview(bio, 40) — exactly the first 40 chars.
|
|
217
|
+
assert projected["bioPreview"] == bio[:40]
|
|
218
|
+
assert len(projected["bioPreview"]) == 40
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ── the counter maintainer (@aggregate / count(), #141) ──────────────────────────
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _counter_update(items):
|
|
225
|
+
"""The THREADCOUNT (counter) Update among a create/remove's transact items."""
|
|
226
|
+
updates = [it["Update"] for it in items if "Update" in it]
|
|
227
|
+
return next(u for u in updates if plain(u["Key"])["PK"].startswith("THREADCOUNT#"))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_counter_maintainer_adds_plus_one_on_create():
|
|
231
|
+
"""`ThreadPost.created` → ThreadCounter.postCount += 1: the create composes the
|
|
232
|
+
ThreadPost Put AND an atomic `ADD postCount 1` onto the THREADCOUNT#<threadId> owner
|
|
233
|
+
row in ONE atomic transact_write_items — the Python mirror of the TS in-process
|
|
234
|
+
counter render. The counter row is a DIFFERENT owner from the collection
|
|
235
|
+
(THREADSUMMARY), so they never collide."""
|
|
236
|
+
client = FakeClient()
|
|
237
|
+
rt = make_runtime(client)
|
|
238
|
+
rt.execute_command_method(
|
|
239
|
+
"ThreadPostCommands",
|
|
240
|
+
"create",
|
|
241
|
+
{"threadId": "t1", "postId": "p1"},
|
|
242
|
+
{
|
|
243
|
+
"threadId": "t1",
|
|
244
|
+
"postId": "p1",
|
|
245
|
+
"body": "hello world",
|
|
246
|
+
"createdAt": "2026-06-01T00:00:00.000Z",
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
items = [c for c in client.calls if c[0] == "transact_write_items"][0][1][
|
|
250
|
+
"TransactItems"
|
|
251
|
+
]
|
|
252
|
+
upd = _counter_update(items)
|
|
253
|
+
assert plain(upd["Key"]) == {"PK": "THREADCOUNT#t1", "SK": "COUNT"}
|
|
254
|
+
# An atomic `ADD #a0 :a0` of +1 (concurrency-safe, no prior read).
|
|
255
|
+
assert upd["UpdateExpression"] == "ADD #a0 :a0"
|
|
256
|
+
assert upd["ExpressionAttributeNames"] == {"#a0": "postCount"}
|
|
257
|
+
assert _DESER.deserialize(upd["ExpressionAttributeValues"][":a0"]) == 1
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_counter_maintainer_adds_minus_one_on_remove():
|
|
261
|
+
"""`ThreadPost.removed` → ThreadCounter.postCount -= 1: the remove composes the
|
|
262
|
+
ThreadPost Delete AND an atomic `ADD postCount -1` onto the THREADCOUNT owner row."""
|
|
263
|
+
client = FakeClient()
|
|
264
|
+
rt = make_runtime(client)
|
|
265
|
+
rt.execute_command_method(
|
|
266
|
+
"ThreadPostCommands",
|
|
267
|
+
"remove",
|
|
268
|
+
{"threadId": "t9", "postId": "p9"},
|
|
269
|
+
{"threadId": "t9", "postId": "p9"},
|
|
270
|
+
)
|
|
271
|
+
items = [c for c in client.calls if c[0] == "transact_write_items"][0][1][
|
|
272
|
+
"TransactItems"
|
|
273
|
+
]
|
|
274
|
+
# The base ThreadPost Delete + the counter ADD(-1).
|
|
275
|
+
deletes = [it["Delete"] for it in items if "Delete" in it]
|
|
276
|
+
assert any(plain(d["Key"])["PK"] == "THREAD#t9" for d in deletes)
|
|
277
|
+
upd = _counter_update(items)
|
|
278
|
+
assert upd["UpdateExpression"] == "ADD #a0 :a0"
|
|
279
|
+
assert upd["ExpressionAttributeNames"] == {"#a0": "postCount"}
|
|
280
|
+
assert _DESER.deserialize(upd["ExpressionAttributeValues"][":a0"]) == -1
|
|
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.5 → graphddb_runtime-0.3.0}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|