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.
- arango_query_core-0.1.0/.github/workflows/ci.yml +33 -0
- arango_query_core-0.1.0/.github/workflows/publish.yml +38 -0
- arango_query_core-0.1.0/.gitignore +13 -0
- arango_query_core-0.1.0/CHANGELOG.md +29 -0
- arango_query_core-0.1.0/LICENSE +21 -0
- arango_query_core-0.1.0/PKG-INFO +103 -0
- arango_query_core-0.1.0/README.md +44 -0
- arango_query_core-0.1.0/arango_query_core/__init__.py +46 -0
- arango_query_core-0.1.0/arango_query_core/aql.py +34 -0
- arango_query_core-0.1.0/arango_query_core/errors.py +7 -0
- arango_query_core-0.1.0/arango_query_core/exec.py +172 -0
- arango_query_core-0.1.0/arango_query_core/extensions.py +53 -0
- arango_query_core-0.1.0/arango_query_core/mapping.py +770 -0
- arango_query_core-0.1.0/arango_query_core/nl/__init__.py +36 -0
- arango_query_core-0.1.0/arango_query_core/nl/engine.py +141 -0
- arango_query_core-0.1.0/arango_query_core/nl/fewshot.py +196 -0
- arango_query_core-0.1.0/arango_query_core/nl/providers.py +401 -0
- arango_query_core-0.1.0/arango_query_core/nl/seams.py +104 -0
- arango_query_core-0.1.0/arango_query_core/owl_rdflib.py +153 -0
- arango_query_core-0.1.0/arango_query_core/owl_turtle.py +252 -0
- arango_query_core-0.1.0/arango_query_core/py.typed +0 -0
- arango_query_core-0.1.0/docs/PRD.md +185 -0
- arango_query_core-0.1.0/pyproject.toml +73 -0
- arango_query_core-0.1.0/tests/fixtures/mappings/pg.owl.ttl +43 -0
- arango_query_core-0.1.0/tests/test_mapping_helpers.py +208 -0
- arango_query_core-0.1.0/tests/test_mapping_resolver_normalization.py +113 -0
- arango_query_core-0.1.0/tests/test_mapping_vci_detection.py +88 -0
- arango_query_core-0.1.0/tests/test_nl_engine.py +118 -0
- arango_query_core-0.1.0/tests/test_nl_fewshot.py +101 -0
- arango_query_core-0.1.0/tests/test_nl_providers.py +78 -0
- arango_query_core-0.1.0/tests/test_owl_rdflib.py +191 -0
- 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,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,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)
|