graphddb-runtime 0.1.1__tar.gz → 0.2.1__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.1.1 → graphddb_runtime-0.2.1}/PKG-INFO +1 -1
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/batch.py +2 -1
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/filters.py +5 -3
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/runtime.py +146 -15
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/transactions.py +84 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/PKG-INFO +1 -1
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/pyproject.toml +1 -1
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_command.py +33 -14
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_unit.py +168 -20
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/README.md +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/__init__.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/async_runtime.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/concurrency.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/cursor.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/errors.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/hydration.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/limits.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/per_key_cursor.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/relations.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime/templates.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/SOURCES.txt +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/requires.txt +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/top_level.txt +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/setup.cfg +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_concurrency.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_contract_runtime.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_compose.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_contract.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_edge_derive.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_edge_write.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_events.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_referential.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_relations.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_integration_unique.py +0 -0
- {graphddb_runtime-0.1.1 → graphddb_runtime-0.2.1}/tests/test_relations.py +0 -0
|
@@ -152,7 +152,8 @@ class BatchWriteExecutor:
|
|
|
152
152
|
|
|
153
153
|
``BatchWriteItem`` carries **no conditions** (DynamoDB has no per-request
|
|
154
154
|
``ConditionExpression`` for it) and is **not atomic** — both are properties of
|
|
155
|
-
the command-contract ``'
|
|
155
|
+
the command-contract ``mode: 'parallel'`` coalesced fan-out (issue #64/#101).
|
|
156
|
+
The sleep is injected
|
|
156
157
|
so unit tests can observe the backoff schedule without real delays.
|
|
157
158
|
"""
|
|
158
159
|
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Port of the TypeScript ``compileFilterExpression``
|
|
4
4
|
(``src/expression/filter-expression.ts``). The declarative tree carried in a
|
|
5
|
-
query spec's ``filter.declarative`` is JSON-safe
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
query spec's ``filter.declarative`` is JSON-safe, so only the operator/logical
|
|
6
|
+
forms are handled here. (A read-side ``cond`` escape hatch is not serialized into
|
|
7
|
+
``filter.declarative``; a *write*-side ``cond`` rides the separate ``raw``
|
|
8
|
+
condition spec — issue #114-B — rendered in ``runtime.py`` / ``transactions.py``,
|
|
9
|
+
not here.)
|
|
8
10
|
|
|
9
11
|
Names are ``#``-aliased columns (reused per distinct column); values are
|
|
10
12
|
``:``-aliased parameters — no literal interpolation. The result is returned in
|
|
@@ -64,6 +64,53 @@ def _normalize_template(template: Optional[str]) -> Optional[str]:
|
|
|
64
64
|
return _PLACEHOLDER_RE.sub("\x00", template)
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def _resolve_condition_leaf(value: Any, params: Mapping[str, Any]) -> Any:
|
|
68
|
+
"""Resolve one declarative-condition operand leaf (issue #114-A).
|
|
69
|
+
|
|
70
|
+
A ``{"$param": name}`` marker is replaced by the **typed** caller-param value
|
|
71
|
+
(so a numeric comparison stays numeric); a native literal is returned as-is.
|
|
72
|
+
"""
|
|
73
|
+
if isinstance(value, dict) and isinstance(value.get("$param"), str):
|
|
74
|
+
name = value["$param"]
|
|
75
|
+
if name not in params or params[name] is None:
|
|
76
|
+
raise KeyError(f"condition references unbound parameter '{name}'")
|
|
77
|
+
return params[name]
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_condition_tree(node: Any, params: Mapping[str, Any]) -> Any:
|
|
82
|
+
"""Resolve every ``{"$param"}`` marker in a serialized condition tree.
|
|
83
|
+
|
|
84
|
+
Walks the JSON-safe operator tree the TS planner emits (field operator
|
|
85
|
+
objects + ``and`` / ``or`` / ``not`` groups), substituting param markers with
|
|
86
|
+
typed caller-param values, and returns a tree shaped exactly like a
|
|
87
|
+
declarative ``filter`` so :func:`compile_filter` compiles it identically.
|
|
88
|
+
"""
|
|
89
|
+
out: Dict[str, Any] = {}
|
|
90
|
+
for key, value in node.items():
|
|
91
|
+
if key in ("and", "or"):
|
|
92
|
+
out[key] = [_resolve_condition_tree(s, params) for s in value]
|
|
93
|
+
elif key == "not":
|
|
94
|
+
out[key] = _resolve_condition_tree(value, params)
|
|
95
|
+
else:
|
|
96
|
+
ops: Dict[str, Any] = {}
|
|
97
|
+
for op, op_val in value.items():
|
|
98
|
+
if op == "between":
|
|
99
|
+
lo, hi = op_val
|
|
100
|
+
ops[op] = [
|
|
101
|
+
_resolve_condition_leaf(lo, params),
|
|
102
|
+
_resolve_condition_leaf(hi, params),
|
|
103
|
+
]
|
|
104
|
+
elif op == "in":
|
|
105
|
+
ops[op] = [_resolve_condition_leaf(v, params) for v in op_val]
|
|
106
|
+
elif op == "attributeExists":
|
|
107
|
+
ops[op] = op_val
|
|
108
|
+
else:
|
|
109
|
+
ops[op] = _resolve_condition_leaf(op_val, params)
|
|
110
|
+
out[key] = ops
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
67
114
|
def _present(records: Any) -> List[Dict[str, Any]]:
|
|
68
115
|
"""The present (non-``None``, dict) parent records from a result collection.
|
|
69
116
|
|
|
@@ -789,11 +836,11 @@ class GraphDDBRuntime:
|
|
|
789
836
|
the method's declared mode:
|
|
790
837
|
|
|
791
838
|
- ``single`` key → the one referenced write op in ``commands``;
|
|
792
|
-
- ``keys[]`` + ``
|
|
839
|
+
- ``keys[]`` + ``transaction`` → one ``TransactWriteItems`` (the synthesized
|
|
793
840
|
per-key ``forEach`` transaction; **atomic**, ≤25, condition-capable). An
|
|
794
841
|
array of **>25 keys is rejected** — an atomic transaction cannot be split.
|
|
795
|
-
- ``keys[]`` + ``
|
|
796
|
-
|
|
842
|
+
- ``keys[]`` + ``parallel`` → a non-atomic per-key fan-out with partial
|
|
843
|
+
success (unconditioned put/delete coalesce into a ``BatchWriteItem``).
|
|
797
844
|
|
|
798
845
|
``params`` are the mutation values shared across every key (the body's
|
|
799
846
|
``params`` argument). For each key the runtime merges ``{**key, **params}``
|
|
@@ -810,9 +857,15 @@ class GraphDDBRuntime:
|
|
|
810
857
|
shared = dict(params or {})
|
|
811
858
|
|
|
812
859
|
if isinstance(key_or_keys, list):
|
|
813
|
-
self._execute_command_batch(
|
|
860
|
+
outcome = self._execute_command_batch(
|
|
814
861
|
contract_name, method_name, method, key_or_keys, shared
|
|
815
862
|
)
|
|
863
|
+
# #101 `mode: 'parallel'` returns a partial-success result list; the
|
|
864
|
+
# atomic / batchWrite forms return None (fire-and-forget). Surface the
|
|
865
|
+
# parallel outcome to the caller wrapped as `{"results": [...]}`, matching
|
|
866
|
+
# the TS runtime's `CommandParallelReturn`.
|
|
867
|
+
if outcome is not None:
|
|
868
|
+
return {"results": outcome}
|
|
816
869
|
return None
|
|
817
870
|
|
|
818
871
|
# Single key → the referenced write surface, driven by key + params.
|
|
@@ -894,15 +947,19 @@ class GraphDDBRuntime:
|
|
|
894
947
|
method: Mapping[str, Any],
|
|
895
948
|
keys: List[Mapping[str, Any]],
|
|
896
949
|
shared: Mapping[str, Any],
|
|
897
|
-
) ->
|
|
898
|
-
"""Apply a command method's array form per its declared
|
|
950
|
+
) -> Optional[List[Dict[str, Any]]]:
|
|
951
|
+
"""Apply a command method's array form per its declared `mode` target.
|
|
952
|
+
|
|
953
|
+
Returns ``None`` for the atomic ``transaction`` form (fire-and-forget), or
|
|
954
|
+
the per-op partial-success result list for the #101 ``parallel`` form."""
|
|
899
955
|
batch = method.get("batch")
|
|
900
956
|
label = f"{contract_name}.{method_name}"
|
|
901
957
|
if batch is None:
|
|
902
958
|
raise ContractArityError(
|
|
903
959
|
f"command method '{label}' was called with an array of keys, but it "
|
|
904
|
-
f"declares no
|
|
905
|
-
f"
|
|
960
|
+
f"declares no key-array bulk form. Author the method with "
|
|
961
|
+
f"`mode: 'transaction'` / `mode: 'parallel'`, or call it with a "
|
|
962
|
+
f"single key."
|
|
906
963
|
)
|
|
907
964
|
mode = batch.get("mode")
|
|
908
965
|
if mode == "transaction":
|
|
@@ -912,13 +969,57 @@ class GraphDDBRuntime:
|
|
|
912
969
|
batch["transaction"], {**dict(shared), "keys": [dict(k) for k in keys]}
|
|
913
970
|
)
|
|
914
971
|
return
|
|
915
|
-
if mode == "
|
|
916
|
-
|
|
917
|
-
|
|
972
|
+
if mode == "parallel":
|
|
973
|
+
# #101 — non-atomic per-key fan-out with partial success. This branch
|
|
974
|
+
# returns a per-op result list; the caller (execute_command_method)
|
|
975
|
+
# surfaces it.
|
|
976
|
+
return self._execute_command_parallel(
|
|
977
|
+
label, batch["operation"], keys, shared
|
|
978
|
+
)
|
|
918
979
|
raise GraphDDBError( # pragma: no cover - serializer only emits the two above
|
|
919
980
|
f"{label}: unknown batch resolution mode '{mode}'"
|
|
920
981
|
)
|
|
921
982
|
|
|
983
|
+
def _execute_command_parallel(
|
|
984
|
+
self,
|
|
985
|
+
label: str,
|
|
986
|
+
command_id: str,
|
|
987
|
+
keys: List[Mapping[str, Any]],
|
|
988
|
+
shared: Mapping[str, Any],
|
|
989
|
+
) -> List[Dict[str, Any]]:
|
|
990
|
+
"""#101 ``mode: 'parallel'`` — non-atomic per-key fan-out, partial success.
|
|
991
|
+
|
|
992
|
+
Mirrors the TS ``executeParallelWrites``: when the per-key op carries no
|
|
993
|
+
condition and is put/delete, coalesce into a ``BatchWriteItem`` (chunk ≤25,
|
|
994
|
+
``UnprocessedItems`` retry) and report every key ``{"ok": True}``; otherwise
|
|
995
|
+
issue each conditional write individually, collecting ``{"ok": bool,
|
|
996
|
+
"error"?}`` per key. A per-op failure NEVER aborts the others. The result
|
|
997
|
+
list is aligned to the input key order.
|
|
998
|
+
"""
|
|
999
|
+
if not keys:
|
|
1000
|
+
return []
|
|
1001
|
+
spec = self._commands.get(command_id)
|
|
1002
|
+
if spec is None:
|
|
1003
|
+
raise ContractNotFoundError(
|
|
1004
|
+
f"{label}: referenced write op '{command_id}' is not present in "
|
|
1005
|
+
f"`commands`."
|
|
1006
|
+
)
|
|
1007
|
+
op_type = spec["type"]
|
|
1008
|
+
has_condition = spec.get("condition") is not None
|
|
1009
|
+
coalescible = not has_condition and op_type in ("PutItem", "DeleteItem")
|
|
1010
|
+
if coalescible:
|
|
1011
|
+
self._execute_batch_write(label, command_id, keys, shared)
|
|
1012
|
+
return [{"ok": True} for _ in keys]
|
|
1013
|
+
results: List[Dict[str, Any]] = []
|
|
1014
|
+
for key in keys:
|
|
1015
|
+
params = {**dict(key), **dict(shared)}
|
|
1016
|
+
try:
|
|
1017
|
+
self.execute_command(command_id, params)
|
|
1018
|
+
results.append({"ok": True})
|
|
1019
|
+
except Exception as exc: # noqa: BLE001 - per-op partial success
|
|
1020
|
+
results.append({"ok": False, "error": str(exc)})
|
|
1021
|
+
return results
|
|
1022
|
+
|
|
922
1023
|
def _execute_batch_write(
|
|
923
1024
|
self,
|
|
924
1025
|
label: str,
|
|
@@ -930,8 +1031,9 @@ class GraphDDBRuntime:
|
|
|
930
1031
|
|
|
931
1032
|
Reuses :class:`BatchWriteExecutor` (chunk ≤25, ``UnprocessedItems`` retry).
|
|
932
1033
|
DynamoDB's ``BatchWriteItem`` supports only ``PutRequest`` / ``DeleteRequest``
|
|
933
|
-
— a command whose single-key op is an ``UpdateItem`` cannot
|
|
934
|
-
array form
|
|
1034
|
+
— a command whose single-key op is an ``UpdateItem`` cannot coalesce its
|
|
1035
|
+
``mode: 'parallel'`` array form into a ``BatchWriteItem`` (it falls back to
|
|
1036
|
+
per-key ``UpdateItem`` calls, or must declare ``mode: 'transaction'``).
|
|
935
1037
|
"""
|
|
936
1038
|
spec = self._commands.get(command_id)
|
|
937
1039
|
if spec is None:
|
|
@@ -942,8 +1044,8 @@ class GraphDDBRuntime:
|
|
|
942
1044
|
op_type = spec["type"]
|
|
943
1045
|
if op_type == "UpdateItem":
|
|
944
1046
|
raise GraphDDBError(
|
|
945
|
-
f"{label}:
|
|
946
|
-
f"BatchWriteItem has no Update request). Declare
|
|
1047
|
+
f"{label}: BatchWriteItem only supports put / delete (DynamoDB's "
|
|
1048
|
+
f"BatchWriteItem has no Update request). Declare `mode: 'transaction'` "
|
|
947
1049
|
f"for a batched update."
|
|
948
1050
|
)
|
|
949
1051
|
requests: List[Dict[str, Any]] = []
|
|
@@ -1613,6 +1715,35 @@ class GraphDDBRuntime:
|
|
|
1613
1715
|
values[v] = self._serializer.serialize(resolved)
|
|
1614
1716
|
clauses.append(f"{n} = {v}")
|
|
1615
1717
|
request["ConditionExpression"] = " AND ".join(clauses)
|
|
1718
|
+
elif kind == "expr":
|
|
1719
|
+
# The declarative operator tree (issue #114-A): resolve each
|
|
1720
|
+
# ``{"$param": name}`` marker to its typed caller-param value, then
|
|
1721
|
+
# compile the tree to a ConditionExpression with the SAME mechanics as
|
|
1722
|
+
# the read-side filter (``compile_filter``) so TS and Python emit an
|
|
1723
|
+
# identical expression / semantics. ``compile_filter`` allocates
|
|
1724
|
+
# ``#f`` / ``:vf`` aliases that never collide with the update ``#c`` /
|
|
1725
|
+
# ``:c`` namespace, so the compiled names/values merge cleanly.
|
|
1726
|
+
concrete = _resolve_condition_tree(condition["declarative"], params)
|
|
1727
|
+
compiled = compile_filter(concrete, self._serializer)
|
|
1728
|
+
if compiled is None:
|
|
1729
|
+
return
|
|
1730
|
+
names.update(compiled["ExpressionAttributeNames"])
|
|
1731
|
+
values = request.setdefault("ExpressionAttributeValues", {})
|
|
1732
|
+
values.update(compiled["ExpressionAttributeValues"])
|
|
1733
|
+
request["ConditionExpression"] = compiled["FilterExpression"]
|
|
1734
|
+
elif kind == "raw":
|
|
1735
|
+
# A raw ``cond`` write condition (issue #114-B): the expression and
|
|
1736
|
+
# the ``#cr_*`` name aliases are finished; bind each ``:crN`` value to
|
|
1737
|
+
# its literal, or — when it is a ``{"$param": name}`` marker — to the
|
|
1738
|
+
# typed caller-param value, then serialize. ``#cr_*`` / ``:cr*`` never
|
|
1739
|
+
# collide with the update namespace, so they merge cleanly. The
|
|
1740
|
+
# expression mirrors the TS ``serializeRawCondition`` output verbatim.
|
|
1741
|
+
names.update(condition["names"])
|
|
1742
|
+
values = request.setdefault("ExpressionAttributeValues", {})
|
|
1743
|
+
for alias, raw_val in condition["values"].items():
|
|
1744
|
+
resolved = _resolve_condition_leaf(raw_val, params)
|
|
1745
|
+
values[alias] = self._serializer.serialize(resolved)
|
|
1746
|
+
request["ConditionExpression"] = condition["expression"]
|
|
1616
1747
|
|
|
1617
1748
|
def _add_key_attributes(
|
|
1618
1749
|
self, entity_name: str, item: Dict[str, Any], params: Mapping[str, Any]
|
|
@@ -18,6 +18,8 @@ from __future__ import annotations
|
|
|
18
18
|
import re
|
|
19
19
|
from typing import Any, Dict, List, Mapping, Optional
|
|
20
20
|
|
|
21
|
+
from .filters import compile_filter
|
|
22
|
+
|
|
21
23
|
_PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
|
|
22
24
|
_WHOLE_PLACEHOLDER_RE = re.compile(r"^\{[^{}]+\}$")
|
|
23
25
|
|
|
@@ -64,6 +66,59 @@ def _resolve_value(
|
|
|
64
66
|
return _resolve_template(template, params, element)
|
|
65
67
|
|
|
66
68
|
|
|
69
|
+
def _resolve_condition_leaf(
|
|
70
|
+
value: Any,
|
|
71
|
+
params: Mapping[str, Any],
|
|
72
|
+
element: Optional[Mapping[str, Any]],
|
|
73
|
+
) -> Any:
|
|
74
|
+
"""Resolve one declarative-condition operand leaf in a transaction (#114-A).
|
|
75
|
+
|
|
76
|
+
A ``{"$param": name}`` marker binds to the typed param value, or — when the
|
|
77
|
+
name is ``item.<field>`` — to the current ``forEach`` element's field; a
|
|
78
|
+
native literal is returned as-is.
|
|
79
|
+
"""
|
|
80
|
+
if isinstance(value, dict) and isinstance(value.get("$param"), str):
|
|
81
|
+
return _resolve_value("{" + value["$param"] + "}", params, element)
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_condition_tree(
|
|
86
|
+
node: Any,
|
|
87
|
+
params: Mapping[str, Any],
|
|
88
|
+
element: Optional[Mapping[str, Any]],
|
|
89
|
+
) -> Any:
|
|
90
|
+
"""Resolve every ``{"$param"}`` marker in a serialized condition tree (#114-A).
|
|
91
|
+
|
|
92
|
+
Mirrors the runtime's resolver but element-aware, producing a tree shaped
|
|
93
|
+
like a declarative ``filter`` for :func:`compile_filter`.
|
|
94
|
+
"""
|
|
95
|
+
out: Dict[str, Any] = {}
|
|
96
|
+
for key, value in node.items():
|
|
97
|
+
if key in ("and", "or"):
|
|
98
|
+
out[key] = [_resolve_condition_tree(s, params, element) for s in value]
|
|
99
|
+
elif key == "not":
|
|
100
|
+
out[key] = _resolve_condition_tree(value, params, element)
|
|
101
|
+
else:
|
|
102
|
+
ops: Dict[str, Any] = {}
|
|
103
|
+
for op, op_val in value.items():
|
|
104
|
+
if op == "between":
|
|
105
|
+
lo, hi = op_val
|
|
106
|
+
ops[op] = [
|
|
107
|
+
_resolve_condition_leaf(lo, params, element),
|
|
108
|
+
_resolve_condition_leaf(hi, params, element),
|
|
109
|
+
]
|
|
110
|
+
elif op == "in":
|
|
111
|
+
ops[op] = [
|
|
112
|
+
_resolve_condition_leaf(v, params, element) for v in op_val
|
|
113
|
+
]
|
|
114
|
+
elif op == "attributeExists":
|
|
115
|
+
ops[op] = op_val
|
|
116
|
+
else:
|
|
117
|
+
ops[op] = _resolve_condition_leaf(op_val, params, element)
|
|
118
|
+
out[key] = ops
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
67
122
|
def _resolve_record(
|
|
68
123
|
record: Optional[Mapping[str, str]],
|
|
69
124
|
params: Mapping[str, Any],
|
|
@@ -318,6 +373,35 @@ class TransactionExpander:
|
|
|
318
373
|
target["ConditionExpression"] = f"{fn}(#ce)"
|
|
319
374
|
return
|
|
320
375
|
values = target.setdefault("ExpressionAttributeValues", {})
|
|
376
|
+
if kind == "expr":
|
|
377
|
+
# Declarative operator tree (issue #114-A): resolve each
|
|
378
|
+
# ``{"$param": name}`` marker against the tx params / forEach element
|
|
379
|
+
# (a ``{item.field}`` name binds from the element), then compile with
|
|
380
|
+
# the SAME ``compile_filter`` mechanics as the read-side filter. The
|
|
381
|
+
# compiled ``#f`` / ``:vf`` aliases never collide with the update
|
|
382
|
+
# ``#u`` namespace, so they merge cleanly.
|
|
383
|
+
concrete = _resolve_condition_tree(
|
|
384
|
+
condition["declarative"], params, element
|
|
385
|
+
)
|
|
386
|
+
compiled = compile_filter(concrete, self._serializer)
|
|
387
|
+
if compiled is None:
|
|
388
|
+
return
|
|
389
|
+
names.update(compiled["ExpressionAttributeNames"])
|
|
390
|
+
values.update(compiled["ExpressionAttributeValues"])
|
|
391
|
+
target["ConditionExpression"] = compiled["FilterExpression"]
|
|
392
|
+
return
|
|
393
|
+
if kind == "raw":
|
|
394
|
+
# A raw ``cond`` write condition (issue #114-B): the expression and
|
|
395
|
+
# ``#cr_*`` aliases are finished; bind each ``:crN`` value to its
|
|
396
|
+
# literal or — when a ``{"$param": name}`` marker — against the tx
|
|
397
|
+
# params / forEach element (a ``{item.field}`` name binds from the
|
|
398
|
+
# element). ``#cr_*`` / ``:cr*`` never collide with the update aliases.
|
|
399
|
+
names.update(condition["names"])
|
|
400
|
+
for alias, raw_val in condition["values"].items():
|
|
401
|
+
resolved = _resolve_condition_leaf(raw_val, params, element)
|
|
402
|
+
values[alias] = self._serializer.serialize(resolved)
|
|
403
|
+
target["ConditionExpression"] = condition["expression"]
|
|
404
|
+
return
|
|
321
405
|
clauses = []
|
|
322
406
|
for i, (field, tmpl) in enumerate(condition["fields"].items()):
|
|
323
407
|
n = f"#e{i}"
|
|
@@ -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.2.1"
|
|
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"
|
|
@@ -6,11 +6,11 @@ fixtures (``UserCommands`` / ``MembershipCommands`` in operations.json) and
|
|
|
6
6
|
asserts the **actual persisted state** after each write:
|
|
7
7
|
|
|
8
8
|
- single key → one write op (update / put / delete);
|
|
9
|
-
- key array + '
|
|
10
|
-
condition: applies on success, rolls the WHOLE batch back on failure);
|
|
11
|
-
- key array + '
|
|
12
|
-
chunked > 25 with retry;
|
|
13
|
-
- a >25-key '
|
|
9
|
+
- key array + `mode: 'transaction'` → one atomic TransactWriteItems (with a
|
|
10
|
+
per-item condition: applies on success, rolls the WHOLE batch back on failure);
|
|
11
|
+
- key array + `mode: 'parallel'` → a non-atomic per-key fan-out (put / delete
|
|
12
|
+
coalesce into BatchWriteItem), chunked > 25 with retry;
|
|
13
|
+
- a >25-key `mode: 'transaction'` array is rejected (an atomic batch cannot be split).
|
|
14
14
|
|
|
15
15
|
This mirrors the TS suite (__tests__/integration/command-runtime.test.ts) over
|
|
16
16
|
the **same SSoT**, proving the two runtimes produce identical effects.
|
|
@@ -147,7 +147,10 @@ def test_single_update_applies(rt, client):
|
|
|
147
147
|
|
|
148
148
|
def test_single_put_then_delete(rt, client):
|
|
149
149
|
rt.execute_command_method(
|
|
150
|
-
"MembershipCommands",
|
|
150
|
+
"MembershipCommands",
|
|
151
|
+
"add",
|
|
152
|
+
{"groupId": "eng", "userId": "solo"},
|
|
153
|
+
{"role": "lead", "joinedAt": "2021-06-01T00:00:00.000Z"},
|
|
151
154
|
)
|
|
152
155
|
row = _membership(client, "eng", "solo")
|
|
153
156
|
assert row is not None and row["role"]["S"] == "lead"
|
|
@@ -172,9 +175,21 @@ def test_transact_array_applies_atomically(rt, client):
|
|
|
172
175
|
|
|
173
176
|
|
|
174
177
|
def test_transact_condition_rolls_back_whole_batch_on_failure(rt, client):
|
|
175
|
-
# Make e 'pending' so its `status = active` condition fails; the atomic
|
|
176
|
-
# must then roll d back too.
|
|
177
|
-
|
|
178
|
+
# Make e 'pending' so its literal `status = active` condition fails; the atomic
|
|
179
|
+
# batch must then roll d back too. (`disable`'s input is `literal('disabled')`,
|
|
180
|
+
# so the 'pending' precondition is written with a raw put rather than the command.)
|
|
181
|
+
client.put_item(
|
|
182
|
+
TableName=TABLE,
|
|
183
|
+
Item={
|
|
184
|
+
"PK": {"S": "USER#e"},
|
|
185
|
+
"SK": {"S": "PROFILE"},
|
|
186
|
+
"userId": {"S": "e"},
|
|
187
|
+
"name": {"S": "User e"},
|
|
188
|
+
"email": {"S": "e@x.com"},
|
|
189
|
+
"status": {"S": "pending"},
|
|
190
|
+
"createdAt": {"S": "2021-01-01T00:00:00.000Z"},
|
|
191
|
+
},
|
|
192
|
+
)
|
|
178
193
|
assert _user_status(client, "e") == "pending"
|
|
179
194
|
|
|
180
195
|
with pytest.raises(OperationExecutionError):
|
|
@@ -182,7 +197,7 @@ def test_transact_condition_rolls_back_whole_batch_on_failure(rt, client):
|
|
|
182
197
|
"UserCommands",
|
|
183
198
|
"disableIfActive",
|
|
184
199
|
[{"userId": "d"}, {"userId": "e"}],
|
|
185
|
-
{"status": "disabled"
|
|
200
|
+
{"status": "disabled"},
|
|
186
201
|
)
|
|
187
202
|
# d unchanged (rolled back); e unchanged.
|
|
188
203
|
assert _user_status(client, "d") == "active"
|
|
@@ -194,7 +209,7 @@ def test_transact_condition_applies_when_all_hold(rt, client):
|
|
|
194
209
|
"UserCommands",
|
|
195
210
|
"disableIfActive",
|
|
196
211
|
[{"userId": "f"}],
|
|
197
|
-
{"status": "disabled"
|
|
212
|
+
{"status": "disabled"},
|
|
198
213
|
)
|
|
199
214
|
assert _user_status(client, "f") == "disabled"
|
|
200
215
|
|
|
@@ -205,12 +220,14 @@ def test_transact_rejects_over_25_keys(rt, client):
|
|
|
205
220
|
rt.execute_command_method("UserCommands", "disable", keys, {"status": "disabled"})
|
|
206
221
|
|
|
207
222
|
|
|
208
|
-
# ── array +
|
|
223
|
+
# ── array + mode:'parallel' → non-atomic BatchWriteItem (chunked > 25) ───────
|
|
209
224
|
|
|
210
225
|
|
|
211
226
|
def test_batch_write_puts_and_chunks_over_25(rt, client):
|
|
212
227
|
keys = [{"groupId": "big", "userId": f"m{i}"} for i in range(30)]
|
|
213
|
-
rt.execute_command_method(
|
|
228
|
+
rt.execute_command_method(
|
|
229
|
+
"MembershipCommands", "add", keys, {"role": "member", "joinedAt": "2021-06-01T00:00:00.000Z"}
|
|
230
|
+
)
|
|
214
231
|
assert _membership(client, "big", "m0")["role"]["S"] == "member"
|
|
215
232
|
assert _membership(client, "big", "m24")["role"]["S"] == "member"
|
|
216
233
|
assert _membership(client, "big", "m29")["role"]["S"] == "member"
|
|
@@ -218,7 +235,9 @@ def test_batch_write_puts_and_chunks_over_25(rt, client):
|
|
|
218
235
|
|
|
219
236
|
def test_batch_write_deletes(rt, client):
|
|
220
237
|
keys = [{"groupId": "big", "userId": f"m{i}"} for i in range(30)]
|
|
221
|
-
rt.execute_command_method(
|
|
238
|
+
rt.execute_command_method(
|
|
239
|
+
"MembershipCommands", "add", keys, {"role": "member", "joinedAt": "2021-06-01T00:00:00.000Z"}
|
|
240
|
+
)
|
|
222
241
|
rt.execute_command_method("MembershipCommands", "remove", keys)
|
|
223
242
|
assert _membership(client, "big", "m0") is None
|
|
224
243
|
assert _membership(client, "big", "m29") is None
|
|
@@ -342,6 +342,145 @@ def test_conditional_put_emits_attribute_not_exists_on_named_field():
|
|
|
342
342
|
assert req["ExpressionAttributeNames"]["#ce"] == "PK"
|
|
343
343
|
|
|
344
344
|
|
|
345
|
+
def test_conditional_expr_tree_renders_via_compile_filter():
|
|
346
|
+
"""Issue #114-A: a declarative operator tree (`{ kind: expr, declarative }`)
|
|
347
|
+
resolves its `{$param}` markers (typed) and compiles to a ConditionExpression
|
|
348
|
+
with the read-side filter mechanics (`#f`/`:vf` aliases).
|
|
349
|
+
"""
|
|
350
|
+
client = FakeClient()
|
|
351
|
+
rt = make_runtime(client)
|
|
352
|
+
rt._commands["addGroupMember"]["params"] = {
|
|
353
|
+
**rt._commands["addGroupMember"]["params"],
|
|
354
|
+
"role_0": {"type": "string", "required": True},
|
|
355
|
+
"role_1": {"type": "string", "required": True},
|
|
356
|
+
}
|
|
357
|
+
rt._commands["addGroupMember"]["condition"] = {
|
|
358
|
+
"kind": "expr",
|
|
359
|
+
"declarative": {
|
|
360
|
+
"and": [
|
|
361
|
+
{"role": {"in": [{"$param": "role_0"}, {"$param": "role_1"}]}},
|
|
362
|
+
{"role": {"beginsWith": {"$param": "role"}}},
|
|
363
|
+
]
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
rt.execute_command(
|
|
367
|
+
"addGroupMember",
|
|
368
|
+
{
|
|
369
|
+
"groupId": "eng",
|
|
370
|
+
"userId": "alice",
|
|
371
|
+
"role": "admin",
|
|
372
|
+
"role_0": "admin",
|
|
373
|
+
"role_1": "owner",
|
|
374
|
+
},
|
|
375
|
+
)
|
|
376
|
+
_method, req = client.calls[0]
|
|
377
|
+
assert req["ConditionExpression"] == (
|
|
378
|
+
"(#f0 IN (:vf0, :vf1) AND begins_with(#f0, :vf2))"
|
|
379
|
+
)
|
|
380
|
+
assert req["ExpressionAttributeNames"]["#f0"] == "role"
|
|
381
|
+
values = plain(req["ExpressionAttributeValues"])
|
|
382
|
+
assert values[":vf0"] == "admin"
|
|
383
|
+
assert values[":vf1"] == "owner"
|
|
384
|
+
assert values[":vf2"] == "admin"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_conditional_expr_numeric_comparison_keeps_type():
|
|
388
|
+
"""A numeric `{$param}` operand stays numeric (serialized as N), so `gt`
|
|
389
|
+
compares numerically rather than against a stringified value.
|
|
390
|
+
"""
|
|
391
|
+
client = FakeClient()
|
|
392
|
+
rt = make_runtime(client)
|
|
393
|
+
rt._commands["disableUser"]["params"] = {
|
|
394
|
+
**rt._commands["disableUser"]["params"],
|
|
395
|
+
"minVersion": {"type": "number", "required": True},
|
|
396
|
+
}
|
|
397
|
+
rt._commands["disableUser"]["condition"] = {
|
|
398
|
+
"kind": "expr",
|
|
399
|
+
"declarative": {"version": {"gt": {"$param": "minVersion"}}},
|
|
400
|
+
}
|
|
401
|
+
rt.execute_command(
|
|
402
|
+
"disableUser", {"userId": "alice", "status": "disabled", "minVersion": 3}
|
|
403
|
+
)
|
|
404
|
+
_method, req = client.calls[0]
|
|
405
|
+
assert req["ConditionExpression"] == "#f0 > :vf0"
|
|
406
|
+
assert req["ExpressionAttributeValues"][":vf0"] == {"N": "3"}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_conditional_expr_size_renders_size_function():
|
|
410
|
+
"""Audit fix (issue #114-A): the `size` operator must compile to
|
|
411
|
+
`size(#f) = :v`, not be silently dropped. The numeric length operand stays
|
|
412
|
+
N-typed.
|
|
413
|
+
"""
|
|
414
|
+
client = FakeClient()
|
|
415
|
+
rt = make_runtime(client)
|
|
416
|
+
rt._commands["addGroupMember"]["params"] = {
|
|
417
|
+
**rt._commands["addGroupMember"]["params"],
|
|
418
|
+
"role_size": {"type": "number", "required": True},
|
|
419
|
+
}
|
|
420
|
+
rt._commands["addGroupMember"]["condition"] = {
|
|
421
|
+
"kind": "expr",
|
|
422
|
+
"declarative": {"role": {"size": {"$param": "role_size"}}},
|
|
423
|
+
}
|
|
424
|
+
rt.execute_command(
|
|
425
|
+
"addGroupMember",
|
|
426
|
+
{"groupId": "eng", "userId": "alice", "role": "admin", "role_size": 5},
|
|
427
|
+
)
|
|
428
|
+
_method, req = client.calls[0]
|
|
429
|
+
assert req["ConditionExpression"] == "size(#f0) = :vf0"
|
|
430
|
+
assert req["ExpressionAttributeNames"]["#f0"] == "role"
|
|
431
|
+
assert req["ExpressionAttributeValues"][":vf0"] == {"N": "5"}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def test_conditional_raw_cond_literal_renders_verbatim():
|
|
435
|
+
"""Issue #114-B: a raw `cond` write condition (`{ kind: raw }`) attaches its
|
|
436
|
+
pre-compiled expression + `#cr_*` names verbatim, binding each `:crN` literal
|
|
437
|
+
value (here 'banned') exactly as the TS `serializeRawCondition` emitted it.
|
|
438
|
+
"""
|
|
439
|
+
client = FakeClient()
|
|
440
|
+
rt = make_runtime(client)
|
|
441
|
+
rt._commands["addGroupMember"]["condition"] = {
|
|
442
|
+
"kind": "raw",
|
|
443
|
+
"expression": "#cr_role <> :cr0 AND attribute_exists(#cr_role)",
|
|
444
|
+
"names": {"#cr_role": "role"},
|
|
445
|
+
"values": {":cr0": "banned"},
|
|
446
|
+
}
|
|
447
|
+
rt.execute_command(
|
|
448
|
+
"addGroupMember", {"groupId": "eng", "userId": "alice", "role": "admin"}
|
|
449
|
+
)
|
|
450
|
+
_method, req = client.calls[0]
|
|
451
|
+
assert req["ConditionExpression"] == (
|
|
452
|
+
"#cr_role <> :cr0 AND attribute_exists(#cr_role)"
|
|
453
|
+
)
|
|
454
|
+
assert req["ExpressionAttributeNames"]["#cr_role"] == "role"
|
|
455
|
+
assert plain(req["ExpressionAttributeValues"])[":cr0"] == "banned"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def test_conditional_raw_cond_binds_param_marker():
|
|
459
|
+
"""Issue #114-B: a `{ $param }` value marker in a raw `cond` condition binds
|
|
460
|
+
from the typed caller param (here `cond_p0`), so TS and Python compare against
|
|
461
|
+
the same bound value.
|
|
462
|
+
"""
|
|
463
|
+
client = FakeClient()
|
|
464
|
+
rt = make_runtime(client)
|
|
465
|
+
rt._commands["addGroupMember"]["params"] = {
|
|
466
|
+
**rt._commands["addGroupMember"]["params"],
|
|
467
|
+
"cond_p0": {"type": "string", "required": True},
|
|
468
|
+
}
|
|
469
|
+
rt._commands["addGroupMember"]["condition"] = {
|
|
470
|
+
"kind": "raw",
|
|
471
|
+
"expression": "#cr_role = :cr0",
|
|
472
|
+
"names": {"#cr_role": "role"},
|
|
473
|
+
"values": {":cr0": {"$param": "cond_p0"}},
|
|
474
|
+
}
|
|
475
|
+
rt.execute_command(
|
|
476
|
+
"addGroupMember",
|
|
477
|
+
{"groupId": "eng", "userId": "alice", "role": "admin", "cond_p0": "admin"},
|
|
478
|
+
)
|
|
479
|
+
_method, req = client.calls[0]
|
|
480
|
+
assert req["ConditionExpression"] == "#cr_role = :cr0"
|
|
481
|
+
assert plain(req["ExpressionAttributeValues"])[":cr0"] == "admin"
|
|
482
|
+
|
|
483
|
+
|
|
345
484
|
# ── declarative transactions (issue #46) ────────────────────────────────────
|
|
346
485
|
|
|
347
486
|
|
|
@@ -901,7 +1040,7 @@ def test_command_method_single_update_runs_one_update_item():
|
|
|
901
1040
|
|
|
902
1041
|
|
|
903
1042
|
def test_command_method_transact_array_runs_one_transaction():
|
|
904
|
-
"""A key array + '
|
|
1043
|
+
"""A key array + `mode: 'transaction'` → ONE TransactWriteItems (one item per key)."""
|
|
905
1044
|
client = FakeClient()
|
|
906
1045
|
rt = make_runtime(client)
|
|
907
1046
|
rt.execute_command_method(
|
|
@@ -920,26 +1059,28 @@ def test_command_method_transact_array_runs_one_transaction():
|
|
|
920
1059
|
|
|
921
1060
|
|
|
922
1061
|
def test_command_method_transact_carries_per_item_condition():
|
|
923
|
-
"""A conditional '
|
|
1062
|
+
"""A conditional `mode: 'transaction'` batch attaches the equality condition per item."""
|
|
924
1063
|
client = FakeClient()
|
|
925
1064
|
rt = make_runtime(client)
|
|
926
1065
|
rt.execute_command_method(
|
|
927
1066
|
"UserCommands",
|
|
928
1067
|
"disableIfActive",
|
|
929
1068
|
[{"userId": "a"}, {"userId": "b"}],
|
|
930
|
-
{"status": "disabled"
|
|
1069
|
+
{"status": "disabled"},
|
|
931
1070
|
)
|
|
932
1071
|
method, req = client.calls[0]
|
|
933
1072
|
assert method == "transact_write_items"
|
|
934
1073
|
update = req["TransactItems"][0]["Update"]
|
|
935
1074
|
assert "ConditionExpression" in update
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
]
|
|
1075
|
+
# #101 descriptor form: the gate is a concrete-literal equality (status == 'active');
|
|
1076
|
+
# the written value is the 'disabled' status param. Both appear per item.
|
|
1077
|
+
values = [_DESER.deserialize(v) for v in update["ExpressionAttributeValues"].values()]
|
|
1078
|
+
assert "active" in values
|
|
1079
|
+
assert "disabled" in values
|
|
939
1080
|
|
|
940
1081
|
|
|
941
1082
|
def test_command_method_transact_rejects_over_25_keys():
|
|
942
|
-
"""A >25-key '
|
|
1083
|
+
"""A >25-key `mode: 'transaction'` array is rejected — an atomic tx cannot be split."""
|
|
943
1084
|
client = FakeClient()
|
|
944
1085
|
rt = make_runtime(client)
|
|
945
1086
|
keys = [{"userId": f"u{i}"} for i in range(26)]
|
|
@@ -949,32 +1090,38 @@ def test_command_method_transact_rejects_over_25_keys():
|
|
|
949
1090
|
assert all(m != "transact_write_items" for m, _ in client.calls)
|
|
950
1091
|
|
|
951
1092
|
|
|
952
|
-
def
|
|
953
|
-
"""
|
|
1093
|
+
def test_command_method_parallel_guarded_create_runs_individual_conditional_puts():
|
|
1094
|
+
"""#101 `mode: 'parallel'` + a guarded create (attribute_not_exists) → INDIVIDUAL
|
|
1095
|
+
conditional PutItem writes (not a condition-less BatchWriteItem), with per-key
|
|
1096
|
+
partial-success results aligned to key order."""
|
|
954
1097
|
client = FakeClient()
|
|
955
1098
|
rt = make_runtime(client)
|
|
956
|
-
rt.execute_command_method(
|
|
1099
|
+
out = rt.execute_command_method(
|
|
957
1100
|
"MembershipCommands",
|
|
958
1101
|
"add",
|
|
959
1102
|
[{"groupId": "eng", "userId": "a"}, {"groupId": "eng", "userId": "b"}],
|
|
960
|
-
{"role": "member"},
|
|
1103
|
+
{"role": "member", "joinedAt": "1970-01-01T00:00:00Z"},
|
|
961
1104
|
)
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1105
|
+
# A guarded create carries a condition, so the parallel path issues one PutItem
|
|
1106
|
+
# per key (concurrently) — never a coalesced BatchWriteItem.
|
|
1107
|
+
methods = [m for m, _ in client.calls]
|
|
1108
|
+
assert methods == ["put_item", "put_item"]
|
|
1109
|
+
first = client.calls[0][1]
|
|
1110
|
+
assert "ConditionExpression" in first
|
|
1111
|
+
item0 = plain(first["Item"])
|
|
968
1112
|
assert item0["PK"] == "GROUP#eng"
|
|
969
1113
|
assert item0["SK"] == "USER#a"
|
|
970
1114
|
assert item0["role"] == "member"
|
|
1115
|
+
# Partial-success result: both keys ok, aligned to input order.
|
|
1116
|
+
assert out == {"results": [{"ok": True}, {"ok": True}]}
|
|
971
1117
|
|
|
972
1118
|
|
|
973
|
-
def
|
|
974
|
-
"""
|
|
1119
|
+
def test_command_method_parallel_unconditioned_delete_coalesces_batch_write():
|
|
1120
|
+
"""#101 `mode: 'parallel'` + an UNconditioned delete coalesces into a
|
|
1121
|
+
BatchWriteItem (DeleteRequests), reporting every key ok."""
|
|
975
1122
|
client = FakeClient()
|
|
976
1123
|
rt = make_runtime(client)
|
|
977
|
-
rt.execute_command_method(
|
|
1124
|
+
out = rt.execute_command_method(
|
|
978
1125
|
"MembershipCommands",
|
|
979
1126
|
"remove",
|
|
980
1127
|
[{"groupId": "eng", "userId": "a"}, {"groupId": "eng", "userId": "b"}],
|
|
@@ -986,6 +1133,7 @@ def test_command_method_batch_write_delete_runs_delete_requests():
|
|
|
986
1133
|
"PK": "GROUP#eng",
|
|
987
1134
|
"SK": "USER#a",
|
|
988
1135
|
}
|
|
1136
|
+
assert out == {"results": [{"ok": True}, {"ok": True}]}
|
|
989
1137
|
|
|
990
1138
|
|
|
991
1139
|
def test_command_method_unknown_contract_raises():
|
|
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.1.1 → graphddb_runtime-0.2.1}/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
|