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.
- arango_query_core/__init__.py +46 -0
- arango_query_core/aql.py +34 -0
- arango_query_core/errors.py +7 -0
- arango_query_core/exec.py +172 -0
- arango_query_core/extensions.py +53 -0
- arango_query_core/mapping.py +770 -0
- arango_query_core/nl/__init__.py +36 -0
- arango_query_core/nl/engine.py +141 -0
- arango_query_core/nl/fewshot.py +196 -0
- arango_query_core/nl/providers.py +401 -0
- arango_query_core/nl/seams.py +104 -0
- arango_query_core/owl_rdflib.py +153 -0
- arango_query_core/owl_turtle.py +252 -0
- arango_query_core/py.typed +0 -0
- arango_query_core-0.1.0.dist-info/METADATA +103 -0
- arango_query_core-0.1.0.dist-info/RECORD +18 -0
- arango_query_core-0.1.0.dist-info/WHEEL +4 -0
- arango_query_core-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
arango_query_core/aql.py
ADDED
|
@@ -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,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)
|