arango-query-core 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,46 @@
1
+ from .aql import AqlFragment, AqlQuery
2
+ from .errors import CoreError
3
+ from .exec import AqlExecutor, explain_aql, safe_execute
4
+ from .extensions import ExtensionPolicy, ExtensionRegistry
5
+ from .mapping import (
6
+ COLLECTION_NAME_RE,
7
+ IndexInfo,
8
+ MappingBundle,
9
+ MappingResolver,
10
+ MappingSource,
11
+ PropertyInfo,
12
+ RelationshipStats,
13
+ is_valid_collection_name,
14
+ mapping_from_wire_dict,
15
+ mapping_hash,
16
+ )
17
+ from .owl_turtle import mapping_to_turtle, turtle_to_mapping
18
+
19
+ try:
20
+ from .owl_rdflib import parse_owl_with_rdflib
21
+ except ImportError:
22
+ parse_owl_with_rdflib = None # type: ignore[assignment]
23
+
24
+ __all__ = [
25
+ "COLLECTION_NAME_RE",
26
+ "AqlExecutor",
27
+ "AqlFragment",
28
+ "AqlQuery",
29
+ "CoreError",
30
+ "ExtensionPolicy",
31
+ "ExtensionRegistry",
32
+ "explain_aql",
33
+ "safe_execute",
34
+ "IndexInfo",
35
+ "MappingBundle",
36
+ "MappingResolver",
37
+ "MappingSource",
38
+ "PropertyInfo",
39
+ "RelationshipStats",
40
+ "is_valid_collection_name",
41
+ "mapping_from_wire_dict",
42
+ "mapping_hash",
43
+ "mapping_to_turtle",
44
+ "parse_owl_with_rdflib",
45
+ "turtle_to_mapping",
46
+ ]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from .errors import CoreError
7
+
8
+
9
+ @dataclass
10
+ class AqlFragment:
11
+ text: str
12
+ bind_vars: dict[str, Any] = field(default_factory=dict)
13
+
14
+ def __add__(self, other: AqlFragment) -> AqlFragment:
15
+ if not isinstance(other, AqlFragment):
16
+ return NotImplemented
17
+ text = self.text.rstrip()
18
+ other_text = other.text.lstrip()
19
+ joined = (text + "\n" + other_text).strip() if text and other_text else (text + other_text).strip()
20
+
21
+ merged: dict[str, Any] = dict(self.bind_vars)
22
+ for k, v in other.bind_vars.items():
23
+ if k in merged and merged[k] != v:
24
+ raise CoreError(f"Bind var collision for {k!r}", code="BIND_VAR_COLLISION")
25
+ merged[k] = v
26
+ return AqlFragment(text=joined, bind_vars=merged)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class AqlQuery:
31
+ text: str
32
+ bind_vars: dict[str, Any]
33
+ debug: dict[str, Any] | None = None
34
+ warnings: tuple[str, ...] = ()
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class CoreError(Exception):
5
+ def __init__(self, message: str, *, code: str = "CORE_ERROR"):
6
+ super().__init__(message)
7
+ self.code = code
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from .aql import AqlQuery
9
+
10
+
11
+ def _aql_references_bindvar(aql: str, name: str) -> bool:
12
+ """Whether *aql* references the value bind parameter ``@name``.
13
+
14
+ ArangoDB rejects a query (``[ERR 1552] bind parameter '<name>' was
15
+ not declared in the query``) when ``bind_vars`` carries a key the
16
+ query text never uses. The Layer-6 session-spread must therefore
17
+ add ``tenantId`` / ``tenantKey`` **only** when the AQL actually
18
+ references them — otherwise a perfectly safe global / satellite-only
19
+ read (e.g. ``FOR c IN Country RETURN c``) explodes at EXPLAIN time
20
+ just because the session injected an unused tenant bind.
21
+
22
+ Matches a single-``@`` *value* reference (``@tenantId``) and
23
+ deliberately ignores the doubled *collection* form (``@@tenantId``)
24
+ — tenant scope binds are always value binds. The name boundary
25
+ stops ``@tenantId`` from matching a longer identifier such as
26
+ ``@tenantIdentifier``.
27
+
28
+ This is a textual heuristic, mirroring Layer 4's textual splice
29
+ approach. A false positive (``@tenantId`` appearing only inside an
30
+ AQL comment or string literal) would re-introduce ERR 1552, but the
31
+ transpiler and Layer 3/4 only ever emit the token in real predicate
32
+ positions, so the heuristic is safe for produced AQL.
33
+ """
34
+ return re.search(r"(?<![@\w])@" + re.escape(name) + r"(?![\w])", aql) is not None
35
+
36
+
37
+ @dataclass
38
+ class AqlExecutor:
39
+ db: Any # python-arango Database
40
+
41
+ def execute(self, query: AqlQuery, *, batch_size: int | None = None, **kwargs: Any) -> Any:
42
+ aql = self.db.aql
43
+ return aql.execute(query.text, bind_vars=query.bind_vars, batch_size=batch_size, **kwargs)
44
+
45
+
46
+ def safe_execute(
47
+ *,
48
+ db: Any,
49
+ aql: str,
50
+ client_bind_vars: dict[str, Any] | None,
51
+ session: Any,
52
+ validator: Callable[..., None],
53
+ manifest: Any,
54
+ sharding_profile: dict[str, Any] | None,
55
+ collection_to_entity: dict[str, str] | None = None,
56
+ execute_kwargs: dict[str, Any] | None = None,
57
+ admin_bypass: bool = False,
58
+ bypass_reason: str = "",
59
+ ) -> tuple[Any, dict[str, Any]]:
60
+ """Layer 6 — execute AQL only after Layer 5 has certified the plan.
61
+
62
+ Implements the contract from ``docs/multitenant_prd.md`` §9 / Wave 7
63
+ part 4. The bind-var spread order is load-bearing:
64
+
65
+ .. code-block:: python
66
+
67
+ bind_vars = {
68
+ **client_bind_vars,
69
+ "tenantId": session.tenant_id,
70
+ "tenantKey": session.tenant_key,
71
+ }
72
+
73
+ The session value silently overrides any caller-supplied
74
+ ``tenantId`` / ``tenantKey``. Layer 5 (``validator``) then
75
+ verifies the bind-var matches the session — closing T7 (bind-var
76
+ override) by construction.
77
+
78
+ The session binds are spread **only when the AQL references the
79
+ corresponding ``@tenantId`` / ``@tenantKey`` parameter**. ArangoDB
80
+ refuses a query that declares a bind value it never uses
81
+ (``[ERR 1552] bind parameter '<name>' was not declared in the
82
+ query``), so unconditionally injecting the session tenant would
83
+ blow up every safe global / satellite-only read at EXPLAIN time.
84
+ A query that *does* reference the tenant bind always has the
85
+ session value win; a client-supplied ``tenantId`` on a query that
86
+ never uses ``@tenantId`` is inert and is dropped (it cannot affect
87
+ results and would only trip ERR 1552).
88
+
89
+ Returns ``(cursor, bind_vars)`` so the caller can echo the final
90
+ bind vars back to the UI for transparency (§9.2). The validator is
91
+ injected rather than imported here so ``arango_query_core`` stays
92
+ free of an ``arango_cypher`` reverse dependency.
93
+
94
+ Raises whatever the validator raises — typically
95
+ ``arango_cypher.tenant_plan_validator.TenantScopeViolation`` — on
96
+ refusal. The execute never runs in that case.
97
+ """
98
+ if session is None:
99
+ raise PermissionError("safe_execute: no authenticated session; cannot bind tenant context")
100
+ bind_vars = dict(client_bind_vars or {})
101
+ if _aql_references_bindvar(aql, "tenantId"):
102
+ bind_vars["tenantId"] = getattr(session, "tenant_id", None)
103
+ else:
104
+ bind_vars.pop("tenantId", None)
105
+ if _aql_references_bindvar(aql, "tenantKey"):
106
+ bind_vars["tenantKey"] = getattr(session, "tenant_key", None)
107
+ else:
108
+ bind_vars.pop("tenantKey", None)
109
+
110
+ validator(
111
+ db=db,
112
+ aql=aql,
113
+ bind_vars=bind_vars,
114
+ manifest=manifest,
115
+ sharding_profile=sharding_profile,
116
+ collection_to_entity=collection_to_entity,
117
+ session=session,
118
+ admin_bypass=admin_bypass,
119
+ bypass_reason=bypass_reason,
120
+ )
121
+
122
+ cursor = db.aql.execute(aql, bind_vars=bind_vars, **(execute_kwargs or {}))
123
+ return cursor, bind_vars
124
+
125
+
126
+ def explain_aql(
127
+ db: Any,
128
+ aql: str,
129
+ bind_vars: dict[str, Any] | None = None,
130
+ ) -> tuple[bool, str]:
131
+ """Plan the query via ``POST /_api/explain`` without executing it.
132
+
133
+ Returns ``(ok, error_message)``. On success, *error_message* is the
134
+ empty string. On failure, it's a short, LLM-friendly description of
135
+ the planner error — short enough to paste back into a retry prompt,
136
+ and stripped of stack traces, HTTP payloads, and sensitive detail.
137
+
138
+ This is the hook WP-25.3 uses to catch semantic errors
139
+ (non-existent collections/properties, invalid traversal directions)
140
+ that ANTLR's grammar-only check happily waves through. We never
141
+ execute the query — the ``explain`` endpoint only plans it, so there
142
+ is no cost to row materialization.
143
+
144
+ Safe to call against any read-only or read-write database: the AQL
145
+ is only planned, never run. Caller is responsible for ensuring
146
+ *db* is a valid python-arango database handle.
147
+ """
148
+ bv = dict(bind_vars or {})
149
+ try:
150
+ result = db.aql.explain(aql, bind_vars=bv)
151
+ except Exception as exc:
152
+ return False, _summarize_explain_error(exc)
153
+
154
+ if isinstance(result, dict) and result.get("error"):
155
+ msg = str(result.get("errorMessage") or result.get("error") or "EXPLAIN failed")
156
+ return False, msg[:500]
157
+ return True, ""
158
+
159
+
160
+ def _summarize_explain_error(exc: BaseException) -> str:
161
+ """Collapse a python-arango / server error into a single short line.
162
+
163
+ python-arango raises ``AQLQueryExplainError`` or ``ArangoServerError``
164
+ whose ``str()`` can include multi-line HTTP payloads and stack frames.
165
+ We strip to the most informative line for LLM feedback.
166
+ """
167
+ msg = str(exc) or exc.__class__.__name__
168
+ msg = msg.splitlines()[0] if "\n" in msg else msg
169
+ msg = msg.strip()
170
+ if len(msg) > 500:
171
+ msg = msg[:497] + "..."
172
+ return msg
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from .errors import CoreError
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ExtensionPolicy:
12
+ enabled: bool = False
13
+ allowlist: set[str] | None = None
14
+ denylist: set[str] | None = None
15
+
16
+ def check_allowed(self, name: str) -> None:
17
+ if not self.enabled:
18
+ raise CoreError(f"Extensions are disabled (attempted: {name})", code="EXTENSIONS_DISABLED")
19
+ if self.denylist and name in self.denylist:
20
+ raise CoreError(f"Extension denied: {name}", code="EXTENSION_DENIED")
21
+ if self.allowlist is not None and name not in self.allowlist:
22
+ raise CoreError(f"Extension not in allowlist: {name}", code="EXTENSION_NOT_ALLOWED")
23
+
24
+
25
+ FunctionCompiler = Callable[[Any, Any], Any]
26
+ ProcedureCompiler = Callable[[Any, Any], Any]
27
+
28
+
29
+ class ExtensionRegistry:
30
+ def __init__(self, *, policy: ExtensionPolicy | None = None):
31
+ self.policy = policy or ExtensionPolicy()
32
+ self._functions: dict[str, FunctionCompiler] = {}
33
+ self._procedures: dict[str, ProcedureCompiler] = {}
34
+
35
+ def register_function(self, name: str, compiler: FunctionCompiler) -> None:
36
+ self._functions[name] = compiler
37
+
38
+ def register_procedure(self, name: str, compiler: ProcedureCompiler) -> None:
39
+ self._procedures[name] = compiler
40
+
41
+ def compile_function(self, name: str, call_ast: Any, ctx: Any) -> Any:
42
+ self.policy.check_allowed(name)
43
+ fn = self._functions.get(name)
44
+ if not fn:
45
+ raise CoreError(f"Unknown extension function: {name}", code="UNKNOWN_EXTENSION")
46
+ return fn(call_ast, ctx)
47
+
48
+ def compile_procedure(self, name: str, call_ast: Any, ctx: Any) -> Any:
49
+ self.policy.check_allowed(name)
50
+ proc = self._procedures.get(name)
51
+ if not proc:
52
+ raise CoreError(f"Unknown extension procedure: {name}", code="UNKNOWN_EXTENSION")
53
+ return proc(call_ast, ctx)