graphddb-runtime 0.7.8__tar.gz → 0.7.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/PKG-INFO +2 -1
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/per_key_cursor.py +18 -6
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/runtime.py +29 -12
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/templates.py +58 -8
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime.egg-info/PKG-INFO +2 -1
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime.egg-info/SOURCES.txt +1 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime.egg-info/requires.txt +1 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/pyproject.toml +7 -2
- graphddb_runtime-0.7.9/tests/test_integration_binding_contract.py +425 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/README.md +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/__init__.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/async_runtime.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/batch.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/concurrency.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/cursor.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/errors.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/filters.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/hydration.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/limits.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/middleware.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/relations.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime/transactions.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/graphddb_runtime.egg-info/top_level.txt +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/setup.cfg +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_concurrency.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_contract_runtime.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_crosslang_python_cells.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_description.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_description_followups.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_description_jsdoc.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_command.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_compose.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_contract.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_edge_derive.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_edge_write.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_events.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_maintain.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_middleware.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_referential.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_relations.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_integration_unique.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_maintain.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_middleware.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_relations.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_spec_version_guard.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_ttl.py +0 -0
- {graphddb_runtime-0.7.8 → graphddb_runtime-0.7.9}/tests/test_unit.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphddb-runtime
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.9
|
|
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
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Requires-Dist: boto3>=1.26
|
|
9
|
+
Requires-Dist: behavior-contracts==0.1.2
|
|
9
10
|
Provides-Extra: test
|
|
10
11
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
11
12
|
|
|
@@ -29,9 +29,10 @@ envelope shape does not branch on arity).
|
|
|
29
29
|
|
|
30
30
|
from __future__ import annotations
|
|
31
31
|
|
|
32
|
-
import json
|
|
33
32
|
from typing import Any, Mapping, Optional
|
|
34
33
|
|
|
34
|
+
from behavior_contracts import canonical_value as _canonical_value
|
|
35
|
+
|
|
35
36
|
from .cursor import decode_cursor, encode_cursor
|
|
36
37
|
|
|
37
38
|
|
|
@@ -39,12 +40,23 @@ def serialize_contract_key(key: Mapping[str, Any]) -> str:
|
|
|
39
40
|
"""Canonical, cross-runtime-stable string identity of a contract key.
|
|
40
41
|
|
|
41
42
|
Object fields are sorted by name so ``{a, b}`` and ``{b, a}`` serialize
|
|
42
|
-
identically; values are emitted
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
identically; values are emitted as compact JSON. Matches the TS
|
|
44
|
+
``serializeContractKey`` (``JSON.stringify`` over a field-sorted object),
|
|
45
|
+
e.g. ``{"categoryId":"tech"}``.
|
|
46
|
+
|
|
47
|
+
**WS4 adapter (behavior-contracts)**: this is the shared COMMON
|
|
48
|
+
``canonicalValue`` primitive (runtime-boundary.md §2.1 →
|
|
49
|
+
canonical-serialization.md §2: key identity = top-level key sort + compact
|
|
50
|
+
JSON). graphddb now delegates to the extracted shared library instead of
|
|
51
|
+
re-implementing the field-sorted ``json.dumps``. Verified byte-for-byte
|
|
52
|
+
identical for every value shape a contract key carries (string / int / bool /
|
|
53
|
+
null / non-ASCII / nested-dict-insertion-order / array); both the shared and
|
|
54
|
+
the former graphddb impl reject non-JSON-scalar leaves such as ``Decimal``,
|
|
55
|
+
so the boundary is exact.
|
|
45
56
|
"""
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
# ``canonical_value`` performs the top-level key sort + compact JSON itself,
|
|
58
|
+
# so pass the key mapping (as a plain dict) straight through.
|
|
59
|
+
return _canonical_value(dict(key))
|
|
48
60
|
|
|
49
61
|
|
|
50
62
|
def encode_per_key_cursor(
|
|
@@ -19,6 +19,9 @@ from typing import Any, Dict, List, Mapping, Optional, Tuple
|
|
|
19
19
|
|
|
20
20
|
from boto3.dynamodb.types import TypeDeserializer, TypeSerializer
|
|
21
21
|
|
|
22
|
+
from behavior_contracts import EnvelopeFailure as _EnvelopeFailure
|
|
23
|
+
from behavior_contracts import validate_envelope as _validate_envelope
|
|
24
|
+
|
|
22
25
|
from .batch import BatchGetExecutor, BatchWriteExecutor, serialize_key
|
|
23
26
|
from .concurrency import RELATION_TRAVERSAL_CONCURRENCY, map_with_concurrency
|
|
24
27
|
from .cursor import decode_cursor, encode_cursor
|
|
@@ -83,26 +86,40 @@ def _validate_spec_version(document: Mapping[str, Any], label: str) -> None:
|
|
|
83
86
|
(forward-rejected);
|
|
84
87
|
- a missing / malformed version is rejected (an unversioned document
|
|
85
88
|
cannot be trusted to match any vocabulary).
|
|
89
|
+
|
|
90
|
+
**WS4 adapter (behavior-contracts)**: the ``<major>.<minor>`` fail-closed
|
|
91
|
+
comparison mechanism is the shared COMMON ``validate_envelope`` primitive
|
|
92
|
+
(runtime-boundary.md §2.1 — ``validateEnvelope``: DynamoDB-agnostic version
|
|
93
|
+
check). graphddb now delegates the parse + major-equal / minor-<= rule to the
|
|
94
|
+
shared library (which reads the ``version`` field here) and re-raises the
|
|
95
|
+
shared ``EnvelopeFailure`` as graphddb's public ``GraphDDBError`` with the
|
|
96
|
+
same actionable wording (test contract preserved).
|
|
86
97
|
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
try:
|
|
99
|
+
_validate_envelope(
|
|
100
|
+
document,
|
|
101
|
+
SPEC_VERSION_SUPPORTED,
|
|
102
|
+
field="version",
|
|
103
|
+
label=label,
|
|
104
|
+
)
|
|
105
|
+
except _EnvelopeFailure as exc:
|
|
106
|
+
version = document.get("version") if isinstance(document, Mapping) else None
|
|
107
|
+
if exc.code in ("MISSING_VERSION", "MALFORMED_VERSION"):
|
|
108
|
+
raise GraphDDBError(
|
|
109
|
+
f"{label}: missing or malformed spec version {version!r} — expected "
|
|
110
|
+
f"'<major>.<minor>' (this runtime supports {SPEC_VERSION_SUPPORTED}). "
|
|
111
|
+
f"Regenerate the document with a matching graphddb generator."
|
|
112
|
+
) from exc
|
|
113
|
+
supported_major, supported_minor = (
|
|
114
|
+
int(part) for part in SPEC_VERSION_SUPPORTED.split(".")
|
|
93
115
|
)
|
|
94
|
-
major, minor = (int(part) for part in version.split("."))
|
|
95
|
-
supported_major, supported_minor = (
|
|
96
|
-
int(part) for part in SPEC_VERSION_SUPPORTED.split(".")
|
|
97
|
-
)
|
|
98
|
-
if major != supported_major or minor > supported_minor:
|
|
99
116
|
raise GraphDDBError(
|
|
100
117
|
f"{label}: document spec version '{version}' is not supported by this "
|
|
101
118
|
f"runtime (supports major {supported_major}, minor <= {supported_minor}). "
|
|
102
119
|
f"A newer document may carry binding forms this runtime would silently "
|
|
103
120
|
f"mis-execute; upgrade graphddb_runtime or regenerate the document with "
|
|
104
121
|
f"a matching generator version."
|
|
105
|
-
)
|
|
122
|
+
) from exc
|
|
106
123
|
|
|
107
124
|
try: # pragma: no cover - exercised indirectly
|
|
108
125
|
from botocore.exceptions import BotoCoreError, ClientError
|
|
@@ -16,12 +16,31 @@ from __future__ import annotations
|
|
|
16
16
|
import re
|
|
17
17
|
from typing import Any, Mapping
|
|
18
18
|
|
|
19
|
+
from behavior_contracts import TemplateFailure as _TemplateFailure
|
|
20
|
+
from behavior_contracts import render_template as _render_template
|
|
21
|
+
|
|
19
22
|
from .errors import ParameterValidationError
|
|
20
23
|
|
|
21
24
|
# A placeholder is `{` + name (no dot) + `}`. `{result.*}` is matched but flagged
|
|
22
25
|
# separately by the executor so it can raise the multi-op error.
|
|
23
26
|
_PLACEHOLDER = re.compile(r"\{([^{}]+)\}")
|
|
24
27
|
|
|
28
|
+
# Names resolved by a *different* layer than the core `{param}` primitive:
|
|
29
|
+
# `{result.*}` (relation-chain binding, resolved by the runPlan/relation layer)
|
|
30
|
+
# and `{item.*}` (Map/forEach expansion). See runtime-boundary.md §4.1: the
|
|
31
|
+
# shared `render_template` deliberately rejects these scopes (RESULT_SCOPE_IN_CORE)
|
|
32
|
+
# because they belong to graphddb's relation execution, not to the DSL-agnostic
|
|
33
|
+
# core. graphddb's `resolve_template` is overloaded to serve BOTH `{param}` key
|
|
34
|
+
# rendering AND `{result.*}` binding, so the adapter only delegates the former.
|
|
35
|
+
_NON_CORE_SCOPES = ("result.", "item.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _has_non_core_scope(template: str) -> bool:
|
|
39
|
+
return any(
|
|
40
|
+
m.group(1).startswith(_NON_CORE_SCOPES)
|
|
41
|
+
for m in _PLACEHOLDER.finditer(template)
|
|
42
|
+
)
|
|
43
|
+
|
|
25
44
|
|
|
26
45
|
def validate_params(
|
|
27
46
|
params: Mapping[str, Any],
|
|
@@ -118,14 +137,45 @@ def resolve_template(template: str, params: Mapping[str, Any]) -> str:
|
|
|
118
137
|
the single-table layout). Missing params should have been caught by
|
|
119
138
|
:func:`validate_params`; a stray placeholder here raises
|
|
120
139
|
:class:`ParameterValidationError`.
|
|
140
|
+
|
|
141
|
+
**WS4 adapter (behavior-contracts)** — this function is *overloaded* in
|
|
142
|
+
graphddb: it renders both the DSL-agnostic ``{param}`` primitive AND the
|
|
143
|
+
graphddb-specific ``{result.*}`` relation-chain binding (the relation layer
|
|
144
|
+
passes ``{"result.<field>": value}`` params with ``{result.<field>}``
|
|
145
|
+
templates, see ``runtime.py`` ``_relation_key_marker`` / belongsTo fan-out).
|
|
146
|
+
Per runtime-boundary.md §4.1 only the ``{param}`` case is COMMON, so:
|
|
147
|
+
|
|
148
|
+
- **pure ``{param}`` templates** → delegate to the shared COMMON
|
|
149
|
+
``render_template`` (single source of truth for the `{param}` grammar,
|
|
150
|
+
strict missing-key, and Python-parity stringify). The shared
|
|
151
|
+
``TemplateFailure(UNBOUND_PARAM)`` is translated to graphddb's
|
|
152
|
+
``ParameterValidationError`` to preserve graphddb's public error contract.
|
|
153
|
+
- **templates carrying ``{result.*}`` / ``{item.*}``** → keep graphddb's own
|
|
154
|
+
substitution (the shared core deliberately rejects these scopes; they are
|
|
155
|
+
resolved by graphddb's relation execution layer, not by the DSL core).
|
|
156
|
+
|
|
157
|
+
This is the WS4 boundary correction: graphddb's ``resolve_template`` does
|
|
158
|
+
*more* than the shared ``render_template``, so it cannot be swapped wholesale
|
|
159
|
+
— the shared core is wired in for the primitive it genuinely owns.
|
|
121
160
|
"""
|
|
161
|
+
if _has_non_core_scope(template):
|
|
162
|
+
# CONSUMER-SPECIFIC relation-layer binding: substitute every `{name}`
|
|
163
|
+
# (including `{result.*}` / `{item.*}`) with its param value. This is the
|
|
164
|
+
# part the shared core does not own (runtime-boundary.md §4.1).
|
|
165
|
+
def repl(match: "re.Match[str]") -> str:
|
|
166
|
+
name = match.group(1)
|
|
167
|
+
if name not in params or params[name] is None:
|
|
168
|
+
raise ParameterValidationError(
|
|
169
|
+
f"template '{template}' references unbound parameter '{name}'"
|
|
170
|
+
)
|
|
171
|
+
return str(params[name])
|
|
122
172
|
|
|
123
|
-
|
|
124
|
-
name = match.group(1)
|
|
125
|
-
if name not in params or params[name] is None:
|
|
126
|
-
raise ParameterValidationError(
|
|
127
|
-
f"template '{template}' references unbound parameter '{name}'"
|
|
128
|
-
)
|
|
129
|
-
return str(params[name])
|
|
173
|
+
return _PLACEHOLDER.sub(repl, template)
|
|
130
174
|
|
|
131
|
-
|
|
175
|
+
# COMMON `{param}` primitive → shared behavior-contracts.
|
|
176
|
+
try:
|
|
177
|
+
return _render_template(template, params)
|
|
178
|
+
except _TemplateFailure as exc:
|
|
179
|
+
# Preserve graphddb's public error contract (ParameterValidationError)
|
|
180
|
+
# while routing the mechanism through the shared library.
|
|
181
|
+
raise ParameterValidationError(str(exc)) from exc
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphddb-runtime
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.9
|
|
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
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Requires-Dist: boto3>=1.26
|
|
9
|
+
Requires-Dist: behavior-contracts==0.1.2
|
|
9
10
|
Provides-Extra: test
|
|
10
11
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
11
12
|
|
|
@@ -27,6 +27,7 @@ tests/test_description.py
|
|
|
27
27
|
tests/test_description_followups.py
|
|
28
28
|
tests/test_description_jsdoc.py
|
|
29
29
|
tests/test_integration.py
|
|
30
|
+
tests/test_integration_binding_contract.py
|
|
30
31
|
tests/test_integration_command.py
|
|
31
32
|
tests/test_integration_compose.py
|
|
32
33
|
tests/test_integration_contract.py
|
|
@@ -4,12 +4,17 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphddb-runtime"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.9"
|
|
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"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
|
-
|
|
12
|
+
# behavior-contracts (formerly dsl-contracts) epic (WS8): the COMMON runtime
|
|
13
|
+
# primitives (spec-version validation, {param} template rendering, contract-key
|
|
14
|
+
# canonicalisation) are delegated to the extracted shared package
|
|
15
|
+
# ``behavior-contracts`` (import name ``behavior_contracts``). The runtime
|
|
16
|
+
# imports it unconditionally, so it is a HARD dependency.
|
|
17
|
+
dependencies = ["boto3>=1.26", "behavior-contracts==0.1.2"]
|
|
13
18
|
|
|
14
19
|
[project.optional-dependencies]
|
|
15
20
|
test = ["pytest>=7.0"]
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Integration tests for the CONTRACT-typed Python bindings (issue #250 step 5).
|
|
2
|
+
|
|
3
|
+
`test_integration_contract.py` proves the raw contract entry point
|
|
4
|
+
(``execute_query_method`` / ``execute_command_method``) executes the serialized
|
|
5
|
+
cardinality matrix correctly. THIS suite proves the #250 GENERATED layer on top:
|
|
6
|
+
the contract-derived typed repository methods (``group_by_id__get``,
|
|
7
|
+
``members_by_group__list``, ``member_access__get``, ``user_commands__set_tier``,
|
|
8
|
+
…) call those entry points with the right key/params split and return results
|
|
9
|
+
IDENTICAL to a raw entry-point call — for every #250 conformance dimension:
|
|
10
|
+
|
|
11
|
+
- point read (``GroupById.get`` → ``Item | None``, miss → ``None``)
|
|
12
|
+
- unique-GSI point read (``UserByEmail.get`` → GSI Query, ``inputArity 'single'``)
|
|
13
|
+
- range read (``MembersByGroup.list`` → ``{items, cursor}`` connection)
|
|
14
|
+
- range read WITH a live cursor (a >1MB partition page-breaks with NO retrieval
|
|
15
|
+
opts — v1 typed bindings expose none, #250 defers limit/after/order — so the
|
|
16
|
+
connection's ``cursor`` is genuinely non-None through the typed method)
|
|
17
|
+
- External Query composition (``MemberAccess.get`` → composed ``group`` via the
|
|
18
|
+
``GroupById`` contract; absent parent composes ``None``)
|
|
19
|
+
- non-promoted single writes (``UserCommands.setTier`` update and
|
|
20
|
+
``MembershipCommands.add`` create: ``single.mode == 'op'`` in the serialized
|
|
21
|
+
contract — a plain UpdateItem/PutItem, no transaction promotion)
|
|
22
|
+
|
|
23
|
+
Cross-language note: the Go / Rust / PHP bindings-IT suites
|
|
24
|
+
(``go/graphddb_bindings_it/contract_integration_test.go``,
|
|
25
|
+
``rust/graphddb_bindings_it/tests/contract_integration.rs``,
|
|
26
|
+
``php/tests/Integration/ContractBindingsIntegrationTest.php``) seed the SAME
|
|
27
|
+
data (group ``g1``/Engineering, members alice=admin / bob=member, joinedAt
|
|
28
|
+
2021-01-01/-02) and assert the SAME expected values, so the four runtimes are
|
|
29
|
+
pinned to identical observable results for the typed contract surface.
|
|
30
|
+
|
|
31
|
+
Requires DynamoDB Local on port 8000:
|
|
32
|
+
docker compose -f docker-compose.test.yml up -d
|
|
33
|
+
|
|
34
|
+
Marked ``integration`` so it is skipped by ``-m "not integration"``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import importlib.util
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import sys
|
|
43
|
+
|
|
44
|
+
import boto3
|
|
45
|
+
import pytest
|
|
46
|
+
from botocore.config import Config
|
|
47
|
+
|
|
48
|
+
import conftest
|
|
49
|
+
from graphddb_runtime import GraphDDBRuntime
|
|
50
|
+
|
|
51
|
+
pytestmark = pytest.mark.integration
|
|
52
|
+
|
|
53
|
+
ENDPOINT = os.environ.get("DDB_LOCAL_ENDPOINT", "http://localhost:8000")
|
|
54
|
+
TABLE = "UserPermissions"
|
|
55
|
+
|
|
56
|
+
# ── the cross-language shared seed (mirrored in the Go / Rust / PHP suites) ────
|
|
57
|
+
GROUP_ID = "g1"
|
|
58
|
+
GROUP_NAME = "Engineering"
|
|
59
|
+
GROUP_DESCRIPTION = "seeded"
|
|
60
|
+
GROUP_CREATED_AT = "2020-01-01T00:00:00.000Z"
|
|
61
|
+
MEMBERS = [
|
|
62
|
+
("alice", "admin", "2021-01-01T00:00:00.000Z"),
|
|
63
|
+
("bob", "member", "2021-01-02T00:00:00.000Z"),
|
|
64
|
+
]
|
|
65
|
+
USERS = [
|
|
66
|
+
("alice", "alice@example.com", "Alice"),
|
|
67
|
+
("bob", "bob@example.com", "Bob"),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_generated_package():
|
|
72
|
+
"""Import the generated bridge dir as the ``generated_bridge`` package."""
|
|
73
|
+
gen_dir = conftest.FIXTURES_DIR
|
|
74
|
+
pkg_name = "generated_bridge"
|
|
75
|
+
if pkg_name in sys.modules:
|
|
76
|
+
return sys.modules[pkg_name]
|
|
77
|
+
init = os.path.join(gen_dir, "__init__.py")
|
|
78
|
+
spec = importlib.util.spec_from_file_location(
|
|
79
|
+
pkg_name, init, submodule_search_locations=[gen_dir]
|
|
80
|
+
)
|
|
81
|
+
module = importlib.util.module_from_spec(spec)
|
|
82
|
+
sys.modules[pkg_name] = module
|
|
83
|
+
spec.loader.exec_module(module)
|
|
84
|
+
return module
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.fixture(scope="module")
|
|
88
|
+
def client():
|
|
89
|
+
cfg = Config(retries={"max_attempts": 2}, connect_timeout=2, read_timeout=10)
|
|
90
|
+
c = boto3.client(
|
|
91
|
+
"dynamodb",
|
|
92
|
+
endpoint_url=ENDPOINT,
|
|
93
|
+
region_name="local",
|
|
94
|
+
aws_access_key_id="fake",
|
|
95
|
+
aws_secret_access_key="fake",
|
|
96
|
+
config=cfg,
|
|
97
|
+
)
|
|
98
|
+
try:
|
|
99
|
+
c.list_tables()
|
|
100
|
+
except Exception as exc: # pragma: no cover
|
|
101
|
+
pytest.skip(f"DynamoDB Local not reachable at {ENDPOINT}: {exc}")
|
|
102
|
+
yield c
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.fixture(scope="module")
|
|
106
|
+
def table(client):
|
|
107
|
+
try:
|
|
108
|
+
client.delete_table(TableName=TABLE)
|
|
109
|
+
client.get_waiter("table_not_exists").wait(TableName=TABLE)
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
client.create_table(
|
|
113
|
+
TableName=TABLE,
|
|
114
|
+
KeySchema=[
|
|
115
|
+
{"AttributeName": "PK", "KeyType": "HASH"},
|
|
116
|
+
{"AttributeName": "SK", "KeyType": "RANGE"},
|
|
117
|
+
],
|
|
118
|
+
AttributeDefinitions=[
|
|
119
|
+
{"AttributeName": "PK", "AttributeType": "S"},
|
|
120
|
+
{"AttributeName": "SK", "AttributeType": "S"},
|
|
121
|
+
{"AttributeName": "GSI1PK", "AttributeType": "S"},
|
|
122
|
+
{"AttributeName": "GSI1SK", "AttributeType": "S"},
|
|
123
|
+
],
|
|
124
|
+
GlobalSecondaryIndexes=[
|
|
125
|
+
{
|
|
126
|
+
"IndexName": "GSI1",
|
|
127
|
+
"KeySchema": [
|
|
128
|
+
{"AttributeName": "GSI1PK", "KeyType": "HASH"},
|
|
129
|
+
{"AttributeName": "GSI1SK", "KeyType": "RANGE"},
|
|
130
|
+
],
|
|
131
|
+
"Projection": {"ProjectionType": "ALL"},
|
|
132
|
+
}
|
|
133
|
+
],
|
|
134
|
+
BillingMode="PAY_PER_REQUEST",
|
|
135
|
+
)
|
|
136
|
+
client.get_waiter("table_exists").wait(TableName=TABLE)
|
|
137
|
+
yield TABLE
|
|
138
|
+
client.delete_table(TableName=TABLE)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.fixture(scope="module")
|
|
142
|
+
def env(client, table):
|
|
143
|
+
"""Runtime + typed repositories over the shared cross-language seed."""
|
|
144
|
+
bridge = _load_generated_package()
|
|
145
|
+
runtime = GraphDDBRuntime(
|
|
146
|
+
dynamodb_client=client,
|
|
147
|
+
manifest_path=conftest.MANIFEST_PATH,
|
|
148
|
+
operations_path=conftest.OPERATIONS_PATH,
|
|
149
|
+
)
|
|
150
|
+
group_repo = bridge.GroupRepository(runtime)
|
|
151
|
+
membership_repo = bridge.GroupMembershipRepository(runtime)
|
|
152
|
+
user_repo = bridge.UserRepository(runtime)
|
|
153
|
+
|
|
154
|
+
# Group META row (the GroupById.get point target / MemberAccess compose child).
|
|
155
|
+
client.put_item(
|
|
156
|
+
TableName=TABLE,
|
|
157
|
+
Item={
|
|
158
|
+
"PK": {"S": f"GROUP#{GROUP_ID}"},
|
|
159
|
+
"SK": {"S": "META"},
|
|
160
|
+
"groupId": {"S": GROUP_ID},
|
|
161
|
+
"name": {"S": GROUP_NAME},
|
|
162
|
+
"description": {"S": GROUP_DESCRIPTION},
|
|
163
|
+
"createdAt": {"S": GROUP_CREATED_AT},
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
# User PROFILE rows with the unique-GSI email projection (UserByEmail.get).
|
|
167
|
+
for uid, email, name in USERS:
|
|
168
|
+
client.put_item(
|
|
169
|
+
TableName=TABLE,
|
|
170
|
+
Item={
|
|
171
|
+
"PK": {"S": f"USER#{uid}"},
|
|
172
|
+
"SK": {"S": "PROFILE"},
|
|
173
|
+
"GSI1PK": {"S": f"EMAIL#{email}"},
|
|
174
|
+
"GSI1SK": {"S": "PROFILE"},
|
|
175
|
+
"userId": {"S": uid},
|
|
176
|
+
"name": {"S": name},
|
|
177
|
+
"email": {"S": email},
|
|
178
|
+
"status": {"S": "active"},
|
|
179
|
+
"createdAt": {"S": GROUP_CREATED_AT},
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
# Memberships written THROUGH the typed contract command (a non-promoted
|
|
183
|
+
# parallel-mode single create — `single.mode == 'op'`, one plain PutItem).
|
|
184
|
+
for uid, role, joined_at in MEMBERS:
|
|
185
|
+
membership_repo.membership_commands__add(
|
|
186
|
+
group_id=GROUP_ID, user_id=uid, role=role, joined_at=joined_at
|
|
187
|
+
)
|
|
188
|
+
return {
|
|
189
|
+
"runtime": runtime,
|
|
190
|
+
"group": group_repo,
|
|
191
|
+
"membership": membership_repo,
|
|
192
|
+
"user": user_repo,
|
|
193
|
+
"client": client,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── point read (GroupById.get → Item | None) ───────────────────────────────────
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_typed_point_read_matches_raw_entry_point(env):
|
|
201
|
+
typed = env["group"].group_by_id__get(group_id=GROUP_ID)
|
|
202
|
+
raw = env["runtime"].execute_query_method("GroupById", "get", {"groupId": GROUP_ID})
|
|
203
|
+
assert typed is not None
|
|
204
|
+
assert typed == raw
|
|
205
|
+
assert typed["groupId"] == GROUP_ID
|
|
206
|
+
assert typed["name"] == GROUP_NAME
|
|
207
|
+
assert typed["description"] == GROUP_DESCRIPTION
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_typed_point_read_miss_returns_none(env):
|
|
211
|
+
assert env["group"].group_by_id__get(group_id="does-not-exist") is None
|
|
212
|
+
assert (
|
|
213
|
+
env["runtime"].execute_query_method("GroupById", "get", {"groupId": "does-not-exist"})
|
|
214
|
+
is None
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_typed_unique_gsi_point_read_matches_raw_entry_point(env):
|
|
219
|
+
typed = env["user"].user_by_email__get(email="alice@example.com")
|
|
220
|
+
raw = env["runtime"].execute_query_method(
|
|
221
|
+
"UserByEmail", "get", {"email": "alice@example.com"}
|
|
222
|
+
)
|
|
223
|
+
assert typed is not None
|
|
224
|
+
assert typed == raw
|
|
225
|
+
assert typed["userId"] == "alice"
|
|
226
|
+
assert typed["email"] == "alice@example.com"
|
|
227
|
+
assert typed["status"] == "active"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ── range read (MembersByGroup.list → {items, cursor} connection) ──────────────
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_typed_range_read_matches_raw_entry_point(env):
|
|
234
|
+
typed = env["membership"].members_by_group__list(group_id=GROUP_ID)
|
|
235
|
+
raw = env["runtime"].execute_query_method(
|
|
236
|
+
"MembersByGroup", "list", {"groupId": GROUP_ID}
|
|
237
|
+
)
|
|
238
|
+
assert typed == raw
|
|
239
|
+
# Single page → the connection cursor is None.
|
|
240
|
+
assert typed["cursor"] is None
|
|
241
|
+
by_user = {m["userId"]: m for m in typed["items"]}
|
|
242
|
+
assert set(by_user) == {"alice", "bob"}
|
|
243
|
+
assert by_user["alice"]["role"] == "admin"
|
|
244
|
+
assert by_user["bob"]["role"] == "member"
|
|
245
|
+
assert all(m["groupId"] == GROUP_ID for m in typed["items"])
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_typed_range_read_carries_live_cursor_on_page_break(env):
|
|
249
|
+
"""The v1 typed range method exposes NO retrieval opts (#250 defers
|
|
250
|
+
limit/after/order), so a non-None cursor must come from a REAL page break:
|
|
251
|
+
4 members whose role is ~380KB each overflow the 1MB Query page, and the
|
|
252
|
+
typed connection surfaces the per-key cursor. The raw entry point (which
|
|
253
|
+
DOES take opts) continues from that cursor to the remaining rows."""
|
|
254
|
+
client = env["client"]
|
|
255
|
+
big_group = "gbig"
|
|
256
|
+
big_role = "x" * 380_000
|
|
257
|
+
member_ids = [f"m{i}" for i in range(1, 5)]
|
|
258
|
+
for uid in member_ids:
|
|
259
|
+
client.put_item(
|
|
260
|
+
TableName=TABLE,
|
|
261
|
+
Item={
|
|
262
|
+
"PK": {"S": f"GROUP#{big_group}"},
|
|
263
|
+
"SK": {"S": f"USER#{uid}"},
|
|
264
|
+
"groupId": {"S": big_group},
|
|
265
|
+
"userId": {"S": uid},
|
|
266
|
+
"role": {"S": big_role},
|
|
267
|
+
"joinedAt": {"S": "2021-01-01T00:00:00.000Z"},
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
typed = env["membership"].members_by_group__list(group_id=big_group)
|
|
272
|
+
raw = env["runtime"].execute_query_method(
|
|
273
|
+
"MembersByGroup", "list", {"groupId": big_group}
|
|
274
|
+
)
|
|
275
|
+
assert typed == raw
|
|
276
|
+
# The 1MB page break yields a genuine cursor through the TYPED method.
|
|
277
|
+
assert typed["cursor"] is not None
|
|
278
|
+
first_page = [m["userId"] for m in typed["items"]]
|
|
279
|
+
assert 0 < len(first_page) < len(member_ids)
|
|
280
|
+
|
|
281
|
+
# The typed cursor is the SAME per-key envelope the raw entry point resumes
|
|
282
|
+
# from: the continuation yields exactly the remaining members, no overlap.
|
|
283
|
+
rest = env["runtime"].execute_query_method(
|
|
284
|
+
"MembersByGroup", "list", {"groupId": big_group}, {"after": typed["cursor"]}
|
|
285
|
+
)
|
|
286
|
+
rest_ids = [m["userId"] for m in rest["items"]]
|
|
287
|
+
assert set(first_page).isdisjoint(rest_ids)
|
|
288
|
+
assert sorted(first_page + rest_ids) == member_ids
|
|
289
|
+
|
|
290
|
+
# Cleanup: keep the module-scoped seed minimal for the other tests.
|
|
291
|
+
for uid in member_ids:
|
|
292
|
+
client.delete_item(
|
|
293
|
+
TableName=TABLE,
|
|
294
|
+
Key={"PK": {"S": f"GROUP#{big_group}"}, "SK": {"S": f"USER#{uid}"}},
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ── External Query composition (MemberAccess.get → composed group) ─────────────
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_typed_compose_matches_raw_entry_point(env):
|
|
302
|
+
typed = env["membership"].member_access__get(group_id=GROUP_ID, user_id="alice")
|
|
303
|
+
raw = env["runtime"].execute_query_method(
|
|
304
|
+
"MemberAccess", "get", {"groupId": GROUP_ID, "userId": "alice"}
|
|
305
|
+
)
|
|
306
|
+
assert typed is not None
|
|
307
|
+
assert typed == raw
|
|
308
|
+
assert typed["role"] == "admin"
|
|
309
|
+
# The composed parent group (via the GroupById contract), fully projected.
|
|
310
|
+
assert typed["group"] is not None
|
|
311
|
+
assert typed["group"]["groupId"] == GROUP_ID
|
|
312
|
+
assert typed["group"]["name"] == GROUP_NAME
|
|
313
|
+
assert typed["group"]["description"] == GROUP_DESCRIPTION
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_typed_compose_absent_parent_is_none(env):
|
|
317
|
+
client = env["client"]
|
|
318
|
+
# A membership whose group META does not exist → the composition is None.
|
|
319
|
+
client.put_item(
|
|
320
|
+
TableName=TABLE,
|
|
321
|
+
Item={
|
|
322
|
+
"PK": {"S": "GROUP#ghost"},
|
|
323
|
+
"SK": {"S": "USER#alice"},
|
|
324
|
+
"groupId": {"S": "ghost"},
|
|
325
|
+
"userId": {"S": "alice"},
|
|
326
|
+
"role": {"S": "member"},
|
|
327
|
+
"joinedAt": {"S": "2021-01-01T00:00:00.000Z"},
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
typed = env["membership"].member_access__get(group_id="ghost", user_id="alice")
|
|
331
|
+
raw = env["runtime"].execute_query_method(
|
|
332
|
+
"MemberAccess", "get", {"groupId": "ghost", "userId": "alice"}
|
|
333
|
+
)
|
|
334
|
+
assert typed is not None
|
|
335
|
+
assert typed == raw
|
|
336
|
+
assert typed["group"] is None
|
|
337
|
+
client.delete_item(
|
|
338
|
+
TableName=TABLE,
|
|
339
|
+
Key={"PK": {"S": "GROUP#ghost"}, "SK": {"S": "USER#alice"}},
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ── non-promoted single writes (single.mode == 'op') ───────────────────────────
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_fixture_writes_are_serialized_non_promoted():
|
|
347
|
+
"""Document the 'non-promoted' fact this suite covers: both write methods'
|
|
348
|
+
single-key resolution target is a PLAIN op (`mode: 'op'` → one
|
|
349
|
+
UpdateItem/PutItem from operations.commands), not a promoted transaction."""
|
|
350
|
+
with open(conftest.OPERATIONS_PATH, encoding="utf-8") as fh:
|
|
351
|
+
operations = json.load(fh)
|
|
352
|
+
set_tier = operations["contracts"]["UserCommands"]["methods"]["setTier"]
|
|
353
|
+
add = operations["contracts"]["MembershipCommands"]["methods"]["add"]
|
|
354
|
+
assert set_tier["single"] == {"mode": "op", "operation": "UserCommands__setTier"}
|
|
355
|
+
assert add["single"] == {"mode": "op", "operation": "MembershipCommands__add"}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_typed_nonpromoted_update_matches_raw_write(env):
|
|
359
|
+
"""`user_commands__set_tier` (typed, alice) and the raw entry point (bob)
|
|
360
|
+
must persist IDENTICAL rows (modulo the identity fields)."""
|
|
361
|
+
client = env["client"]
|
|
362
|
+
env["user"].user_commands__set_tier(user_id="alice", tier="pro")
|
|
363
|
+
env["runtime"].execute_command_method(
|
|
364
|
+
"UserCommands", "setTier", {"userId": "bob"}, {"tier": "pro"}
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def row(uid):
|
|
368
|
+
item = client.get_item(
|
|
369
|
+
TableName=TABLE,
|
|
370
|
+
Key={"PK": {"S": f"USER#{uid}"}, "SK": {"S": "PROFILE"}},
|
|
371
|
+
ConsistentRead=True,
|
|
372
|
+
)["Item"]
|
|
373
|
+
# Strip the per-user identity attributes; everything else must match.
|
|
374
|
+
for k in ("PK", "GSI1PK", "userId", "name", "email"):
|
|
375
|
+
item.pop(k, None)
|
|
376
|
+
return item
|
|
377
|
+
|
|
378
|
+
alice, bob = row("alice"), row("bob")
|
|
379
|
+
assert alice["tier"] == {"S": "pro"}
|
|
380
|
+
assert alice == bob
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_typed_nonpromoted_create_matches_raw_write(env):
|
|
384
|
+
"""`membership_commands__add` (typed) and the raw entry point must persist
|
|
385
|
+
IDENTICAL membership rows (modulo the member id)."""
|
|
386
|
+
client = env["client"]
|
|
387
|
+
env["membership"].membership_commands__add(
|
|
388
|
+
group_id=GROUP_ID, user_id="typed-w", role="viewer", joined_at="2022-03-04T05:06:07.000Z"
|
|
389
|
+
)
|
|
390
|
+
env["runtime"].execute_command_method(
|
|
391
|
+
"MembershipCommands",
|
|
392
|
+
"add",
|
|
393
|
+
{"groupId": GROUP_ID, "userId": "raw-w"},
|
|
394
|
+
{"role": "viewer", "joinedAt": "2022-03-04T05:06:07.000Z"},
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
def row(uid):
|
|
398
|
+
item = client.get_item(
|
|
399
|
+
TableName=TABLE,
|
|
400
|
+
Key={"PK": {"S": f"GROUP#{GROUP_ID}"}, "SK": {"S": f"USER#{uid}"}},
|
|
401
|
+
ConsistentRead=True,
|
|
402
|
+
)["Item"]
|
|
403
|
+
# Strip the per-member identity attributes (the SK and the user-side
|
|
404
|
+
# GSI1 projection both embed the userId); everything else must match.
|
|
405
|
+
for k in ("SK", "GSI1PK", "userId"):
|
|
406
|
+
item.pop(k, None)
|
|
407
|
+
return item
|
|
408
|
+
|
|
409
|
+
typed_row, raw_row = row("typed-w"), row("raw-w")
|
|
410
|
+
assert typed_row["role"] == {"S": "viewer"}
|
|
411
|
+
assert typed_row == raw_row
|
|
412
|
+
|
|
413
|
+
# …and the typed range read sees both rows exactly as the raw read does.
|
|
414
|
+
typed = env["membership"].members_by_group__list(group_id=GROUP_ID)
|
|
415
|
+
raw = env["runtime"].execute_query_method(
|
|
416
|
+
"MembersByGroup", "list", {"groupId": GROUP_ID}
|
|
417
|
+
)
|
|
418
|
+
assert typed == raw
|
|
419
|
+
assert {"typed-w", "raw-w"} <= {m["userId"] for m in typed["items"]}
|
|
420
|
+
|
|
421
|
+
for uid in ("typed-w", "raw-w"):
|
|
422
|
+
client.delete_item(
|
|
423
|
+
TableName=TABLE,
|
|
424
|
+
Key={"PK": {"S": f"GROUP#{GROUP_ID}"}, "SK": {"S": f"USER#{uid}"}},
|
|
425
|
+
)
|
|
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.7.8 → graphddb_runtime-0.7.9}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|