graphddb-runtime 0.2.0__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.
Files changed (37) hide show
  1. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/PKG-INFO +1 -1
  2. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/filters.py +5 -3
  3. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/runtime.py +76 -0
  4. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/transactions.py +84 -0
  5. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/PKG-INFO +1 -1
  6. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/pyproject.toml +1 -1
  7. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_unit.py +139 -0
  8. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/README.md +0 -0
  9. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/__init__.py +0 -0
  10. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/async_runtime.py +0 -0
  11. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/batch.py +0 -0
  12. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/concurrency.py +0 -0
  13. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/cursor.py +0 -0
  14. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/errors.py +0 -0
  15. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/hydration.py +0 -0
  16. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/limits.py +0 -0
  17. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/per_key_cursor.py +0 -0
  18. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/relations.py +0 -0
  19. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime/templates.py +0 -0
  20. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/SOURCES.txt +0 -0
  21. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
  22. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/requires.txt +0 -0
  23. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/graphddb_runtime.egg-info/top_level.txt +0 -0
  24. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/setup.cfg +0 -0
  25. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_concurrency.py +0 -0
  26. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_contract_runtime.py +0 -0
  27. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_integration.py +0 -0
  28. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_integration_command.py +0 -0
  29. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_integration_compose.py +0 -0
  30. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_integration_contract.py +0 -0
  31. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_integration_edge_derive.py +0 -0
  32. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_integration_edge_write.py +0 -0
  33. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_integration_events.py +0 -0
  34. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_integration_referential.py +0 -0
  35. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_integration_relations.py +0 -0
  36. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_integration_unique.py +0 -0
  37. {graphddb_runtime-0.2.0 → graphddb_runtime-0.2.1}/tests/test_relations.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
@@ -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 (the TS bridge guard rejects
6
- the non-serializable ``cond`` escape hatch), so only the operator/logical forms
7
- are handled here.
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
 
@@ -1668,6 +1715,35 @@ class GraphDDBRuntime:
1668
1715
  values[v] = self._serializer.serialize(resolved)
1669
1716
  clauses.append(f"{n} = {v}")
1670
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"]
1671
1747
 
1672
1748
  def _add_key_attributes(
1673
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}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphddb-runtime"
7
- version = "0.2.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"
@@ -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