arango-query-core 0.1.0__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 (32) hide show
  1. arango_query_core-0.1.0/.github/workflows/ci.yml +33 -0
  2. arango_query_core-0.1.0/.github/workflows/publish.yml +38 -0
  3. arango_query_core-0.1.0/.gitignore +13 -0
  4. arango_query_core-0.1.0/CHANGELOG.md +29 -0
  5. arango_query_core-0.1.0/LICENSE +21 -0
  6. arango_query_core-0.1.0/PKG-INFO +103 -0
  7. arango_query_core-0.1.0/README.md +44 -0
  8. arango_query_core-0.1.0/arango_query_core/__init__.py +46 -0
  9. arango_query_core-0.1.0/arango_query_core/aql.py +34 -0
  10. arango_query_core-0.1.0/arango_query_core/errors.py +7 -0
  11. arango_query_core-0.1.0/arango_query_core/exec.py +172 -0
  12. arango_query_core-0.1.0/arango_query_core/extensions.py +53 -0
  13. arango_query_core-0.1.0/arango_query_core/mapping.py +770 -0
  14. arango_query_core-0.1.0/arango_query_core/nl/__init__.py +36 -0
  15. arango_query_core-0.1.0/arango_query_core/nl/engine.py +141 -0
  16. arango_query_core-0.1.0/arango_query_core/nl/fewshot.py +196 -0
  17. arango_query_core-0.1.0/arango_query_core/nl/providers.py +401 -0
  18. arango_query_core-0.1.0/arango_query_core/nl/seams.py +104 -0
  19. arango_query_core-0.1.0/arango_query_core/owl_rdflib.py +153 -0
  20. arango_query_core-0.1.0/arango_query_core/owl_turtle.py +252 -0
  21. arango_query_core-0.1.0/arango_query_core/py.typed +0 -0
  22. arango_query_core-0.1.0/docs/PRD.md +185 -0
  23. arango_query_core-0.1.0/pyproject.toml +73 -0
  24. arango_query_core-0.1.0/tests/fixtures/mappings/pg.owl.ttl +43 -0
  25. arango_query_core-0.1.0/tests/test_mapping_helpers.py +208 -0
  26. arango_query_core-0.1.0/tests/test_mapping_resolver_normalization.py +113 -0
  27. arango_query_core-0.1.0/tests/test_mapping_vci_detection.py +88 -0
  28. arango_query_core-0.1.0/tests/test_nl_engine.py +118 -0
  29. arango_query_core-0.1.0/tests/test_nl_fewshot.py +101 -0
  30. arango_query_core-0.1.0/tests/test_nl_providers.py +78 -0
  31. arango_query_core-0.1.0/tests/test_owl_rdflib.py +191 -0
  32. arango_query_core-0.1.0/tests/test_owl_turtle_roundtrip.py +166 -0
@@ -0,0 +1,33 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - run: pip install ruff
18
+ - run: ruff check .
19
+ - run: ruff format --check .
20
+
21
+ test:
22
+ runs-on: ubuntu-latest
23
+ strategy:
24
+ matrix:
25
+ python-version: ["3.11", "3.12"]
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: actions/setup-python@v5
29
+ with:
30
+ python-version: ${{ matrix.python-version }}
31
+ - run: pip install -e ".[dev]"
32
+ - run: pytest -q --tb=short
33
+ - run: mypy
@@ -0,0 +1,38 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ # Trusted publishing is registered against ArthurKeen/arango-query-core
11
+ # only — don't attempt to publish from the arango-solutions mirror.
12
+ if: github.repository == 'ArthurKeen/arango-query-core'
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ - run: pip install -e ".[dev]" build twine
19
+ - run: pytest -q --tb=short
20
+ - run: python -m build
21
+ - run: twine check dist/*
22
+ - uses: actions/upload-artifact@v4
23
+ with:
24
+ name: dist
25
+ path: dist/
26
+
27
+ publish:
28
+ needs: build
29
+ runs-on: ubuntu-latest
30
+ environment: pypi
31
+ permissions:
32
+ id-token: write
33
+ steps:
34
+ - uses: actions/download-artifact@v4
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .env
11
+
12
+ # Dark-factory drift queue (personal agent infrastructure)
13
+ .prd-drift-queue/
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to `arango-query-core` are documented here, per
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Consumers pin
5
+ exact versions (contextual-data-fabric CC-9) and re-run their golden /
6
+ eval suites on every bump — pre-1.0, **minor** bumps may break the API
7
+ and this file must say so explicitly.
8
+
9
+ ## [0.1.0] - 2026-07-17
10
+
11
+ Initial standalone release — extraction step 1 (see `docs/PRD.md`).
12
+
13
+ ### Added
14
+
15
+ - Conceptual↔physical model contract, seeded verbatim from
16
+ arango-cypher-py @ `5a1392b`: `MappingBundle`, `MappingResolver`,
17
+ `mapping_from_wire_dict`, `mapping_hash`, collection-name grammar
18
+ guard, OWL/Turtle ⇄ bundle round-trip (`owl` extra), AQL fragment
19
+ types, `safe_execute` / `explain_aql`, extension registry,
20
+ `CoreError` base — with their five test modules.
21
+ - `arango_query_core.nl` — language-agnostic NL→query engine:
22
+ `NLQueryEngine` (generate→validate→repair loop), LLM providers
23
+ (OpenAI / OpenRouter / Anthropic with prompt caching), BM25 few-shot
24
+ retrieval over `(question, query)` pairs (`nl` extra), and the
25
+ five-seam `QueryLanguageAdapter` protocol (`nl.seams`).
26
+ - Zero hard runtime dependencies; `owl` / `nl` / `dev` extras.
27
+ - CI: ruff (lint + format), mypy, pytest on Python 3.11 / 3.12.
28
+
29
+ [0.1.0]: https://github.com/ArthurKeen/arango-query-core/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arthur Keen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: arango-query-core
3
+ Version: 0.1.0
4
+ Summary: Shared conceptual-model and NL substrate for ArangoDB query transpilers (arango-cypher-py, arango-sparql-py)
5
+ Project-URL: Repository, https://github.com/ArthurKeen/arango-query-core
6
+ Project-URL: Changelog, https://github.com/ArthurKeen/arango-query-core/blob/main/CHANGELOG.md
7
+ Project-URL: Issues, https://github.com/ArthurKeen/arango-query-core/issues
8
+ Author: Arthur Keen
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Arthur Keen
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: aql,arangodb,cypher,graph-database,nl2query,ontology,owl,sparql,transpiler
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Operating System :: OS Independent
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Topic :: Database
40
+ Classifier: Topic :: Database :: Front-Ends
41
+ Classifier: Topic :: Software Development :: Libraries
42
+ Classifier: Typing :: Typed
43
+ Requires-Python: >=3.11
44
+ Provides-Extra: dev
45
+ Requires-Dist: mypy>=1.11; extra == 'dev'
46
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
47
+ Requires-Dist: pyyaml>=6.0.0; extra == 'dev'
48
+ Requires-Dist: rank-bm25>=0.2.2; extra == 'dev'
49
+ Requires-Dist: rdflib>=7.0; extra == 'dev'
50
+ Requires-Dist: requests>=2.31; extra == 'dev'
51
+ Requires-Dist: ruff>=0.6.0; extra == 'dev'
52
+ Provides-Extra: nl
53
+ Requires-Dist: pyyaml>=6.0.0; extra == 'nl'
54
+ Requires-Dist: rank-bm25>=0.2.2; extra == 'nl'
55
+ Requires-Dist: requests>=2.31; extra == 'nl'
56
+ Provides-Extra: owl
57
+ Requires-Dist: rdflib>=7.0; extra == 'owl'
58
+ Description-Content-Type: text/markdown
59
+
60
+ # arango-query-core
61
+
62
+ Shared conceptual-model and NL substrate for the ArangoDB query
63
+ transpilers — [`arango-cypher-py`](https://github.com/ArthurKeen/arango-cypher-py)
64
+ (Cypher→AQL) and [`arango-sparql-py`](https://github.com/arango-solutions/arango-sparql-py)
65
+ (SPARQL→AQL) — and, through them, the contextual-data-fabric's
66
+ federated query engine.
67
+
68
+ Carved out of `arango-cypher-py` (which previously shipped
69
+ `arango_query_core` inside its own wheel) so both transpilers and the
70
+ fabric pin one artifact instead of forking the same code three ways.
71
+
72
+ ## What's here
73
+
74
+ | Subpackage / module | Purpose |
75
+ | --- | --- |
76
+ | `arango_query_core.mapping` | `MappingBundle` — the conceptual↔physical model contract (entities, relationships, physical annotations) shared by both transpilers and the analyzers' CSI pipeline |
77
+ | `arango_query_core.owl_turtle` / `owl_rdflib` | OWL/Turtle ⇄ MappingBundle round-trip (rdflib optional, via the `owl` extra) |
78
+ | `arango_query_core.aql` / `exec` / `extensions` / `errors` | AQL fragment types, safe execution helpers, extension registry, error base |
79
+ | `arango_query_core.nl` | Language-agnostic NL→query engine: LLM providers (OpenAI / OpenRouter / Anthropic with prompt caching), BM25 few-shot retrieval, and the generate→validate→repair loop |
80
+ | `arango_query_core.nl.seams` | `QueryLanguageAdapter` — the five language-specific seams an adapter implements (grammar prompt, corpus, validator, repair rules, guardrails) |
81
+
82
+ ## What's deliberately NOT here
83
+
84
+ The per-language adapters. `nl2cypher` (in arango-cypher-py) and
85
+ `nl2sparql` (in arango-sparql-py) implement `QueryLanguageAdapter`
86
+ next to their transpilers, because validating a generated query
87
+ requires the transpiler stack — importing one here would create a
88
+ dependency cycle or force consumers to install both query languages.
89
+ Dependencies point one way: transpilers → this package.
90
+
91
+ ## Install
92
+
93
+ ```bash
94
+ pip install arango-query-core # mapping/aql core, zero deps
95
+ pip install "arango-query-core[owl]" # + rdflib OWL round-trip
96
+ pip install "arango-query-core[nl]" # + NL engine runtime deps
97
+ ```
98
+
99
+ ## Versioning
100
+
101
+ Consumed as a pinned artifact (contextual-data-fabric CC-9): consumers
102
+ pin exact versions and re-run their golden suites on every bump.
103
+ Pre-PyPI, pin a git tag.
@@ -0,0 +1,44 @@
1
+ # arango-query-core
2
+
3
+ Shared conceptual-model and NL substrate for the ArangoDB query
4
+ transpilers — [`arango-cypher-py`](https://github.com/ArthurKeen/arango-cypher-py)
5
+ (Cypher→AQL) and [`arango-sparql-py`](https://github.com/arango-solutions/arango-sparql-py)
6
+ (SPARQL→AQL) — and, through them, the contextual-data-fabric's
7
+ federated query engine.
8
+
9
+ Carved out of `arango-cypher-py` (which previously shipped
10
+ `arango_query_core` inside its own wheel) so both transpilers and the
11
+ fabric pin one artifact instead of forking the same code three ways.
12
+
13
+ ## What's here
14
+
15
+ | Subpackage / module | Purpose |
16
+ | --- | --- |
17
+ | `arango_query_core.mapping` | `MappingBundle` — the conceptual↔physical model contract (entities, relationships, physical annotations) shared by both transpilers and the analyzers' CSI pipeline |
18
+ | `arango_query_core.owl_turtle` / `owl_rdflib` | OWL/Turtle ⇄ MappingBundle round-trip (rdflib optional, via the `owl` extra) |
19
+ | `arango_query_core.aql` / `exec` / `extensions` / `errors` | AQL fragment types, safe execution helpers, extension registry, error base |
20
+ | `arango_query_core.nl` | Language-agnostic NL→query engine: LLM providers (OpenAI / OpenRouter / Anthropic with prompt caching), BM25 few-shot retrieval, and the generate→validate→repair loop |
21
+ | `arango_query_core.nl.seams` | `QueryLanguageAdapter` — the five language-specific seams an adapter implements (grammar prompt, corpus, validator, repair rules, guardrails) |
22
+
23
+ ## What's deliberately NOT here
24
+
25
+ The per-language adapters. `nl2cypher` (in arango-cypher-py) and
26
+ `nl2sparql` (in arango-sparql-py) implement `QueryLanguageAdapter`
27
+ next to their transpilers, because validating a generated query
28
+ requires the transpiler stack — importing one here would create a
29
+ dependency cycle or force consumers to install both query languages.
30
+ Dependencies point one way: transpilers → this package.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install arango-query-core # mapping/aql core, zero deps
36
+ pip install "arango-query-core[owl]" # + rdflib OWL round-trip
37
+ pip install "arango-query-core[nl]" # + NL engine runtime deps
38
+ ```
39
+
40
+ ## Versioning
41
+
42
+ Consumed as a pinned artifact (contextual-data-fabric CC-9): consumers
43
+ pin exact versions and re-run their golden suites on every bump.
44
+ Pre-PyPI, pin a git tag.
@@ -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)