nexaql 0.1.1__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.
- nexaql/__init__.py +0 -0
- nexaql/__main__.py +6 -0
- nexaql/adapters/__init__.py +90 -0
- nexaql/adapters/base.py +47 -0
- nexaql/adapters/duckdb_adapter.py +138 -0
- nexaql/adapters/postgresql.py +106 -0
- nexaql/api/__init__.py +2 -0
- nexaql/api/app.py +58 -0
- nexaql/api/deps.py +75 -0
- nexaql/api/middleware.py +44 -0
- nexaql/api/routes/__init__.py +2 -0
- nexaql/api/routes/chat.py +92 -0
- nexaql/api/routes/execute.py +121 -0
- nexaql/api/routes/ontology.py +23 -0
- nexaql/api/routes/suggest.py +58 -0
- nexaql/api/routes/validate.py +101 -0
- nexaql/chat/__init__.py +2 -0
- nexaql/chat/agent.py +292 -0
- nexaql/chat/prompts.py +244 -0
- nexaql/cli.py +293 -0
- nexaql/config.py +113 -0
- nexaql/data/__init__.py +0 -0
- nexaql/data/sample_ecommerce.yaml +282 -0
- nexaql/data/sample_ecommerce_seed.sql +203 -0
- nexaql/engine/__init__.py +0 -0
- nexaql/engine/dialect.py +662 -0
- nexaql/engine/lexer.py +180 -0
- nexaql/engine/parser.py +537 -0
- nexaql/engine/system_functions.py +307 -0
- nexaql/engine/translator.py +624 -0
- nexaql/engine/types.py +221 -0
- nexaql/engine/validator.py +379 -0
- nexaql/ontology/__init__.py +28 -0
- nexaql/ontology/loader.py +59 -0
- nexaql/ontology/models.py +162 -0
- nexaql/ontology/prompt.py +222 -0
- nexaql/policy/__init__.py +16 -0
- nexaql/policy/context.py +33 -0
- nexaql/policy/enforcer.py +254 -0
- nexaql/policy/masking.py +68 -0
- nexaql/static/assets/QueryEditor-DvovKcLh.js +86 -0
- nexaql/static/assets/index-BjMmBuvn.js +82 -0
- nexaql/static/assets/index-CP8BA9Z6.css +1 -0
- nexaql/static/index.html +13 -0
- nexaql-0.1.1.dist-info/METADATA +382 -0
- nexaql-0.1.1.dist-info/RECORD +49 -0
- nexaql-0.1.1.dist-info/WHEEL +4 -0
- nexaql-0.1.1.dist-info/entry_points.txt +2 -0
- nexaql-0.1.1.dist-info/licenses/LICENSE +189 -0
nexaql/__init__.py
ADDED
|
File without changes
|
nexaql/__main__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Copyright (c) 2026-present NexaQL Contributors
|
|
2
|
+
"""Adapter registry — maps datasource configs to concrete QueryAdapter instances."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, Type
|
|
7
|
+
|
|
8
|
+
from nexaql.adapters.base import AdapterResult, QueryAdapter
|
|
9
|
+
|
|
10
|
+
# Lazy imports to avoid pulling in heavy optional dependencies (asyncpg, duckdb)
|
|
11
|
+
# at module-load time.
|
|
12
|
+
|
|
13
|
+
_ADAPTER_REGISTRY: Dict[str, str] = {
|
|
14
|
+
"postgresql": "nexaql.adapters.postgresql.PostgreSQLAdapter",
|
|
15
|
+
"duckdb": "nexaql.adapters.duckdb_adapter.DuckDBAdapter",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _import_class(dotted_path: str) -> Type[QueryAdapter]:
|
|
20
|
+
"""Import a class from a fully qualified dotted path."""
|
|
21
|
+
module_path, class_name = dotted_path.rsplit(".", 1)
|
|
22
|
+
import importlib
|
|
23
|
+
|
|
24
|
+
module = importlib.import_module(module_path)
|
|
25
|
+
return getattr(module, class_name)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_adapter(datasource_config: Any) -> QueryAdapter:
|
|
29
|
+
"""Instantiate the appropriate :class:`QueryAdapter` for *datasource_config*.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
datasource_config:
|
|
34
|
+
A datasource configuration object (e.g. from ``nexaql.yaml`` or the
|
|
35
|
+
ontology models). Must expose a ``type`` attribute (``str``) and may
|
|
36
|
+
carry adapter-specific fields such as ``url`` or ``path``.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
QueryAdapter
|
|
41
|
+
A ready-to-use adapter instance.
|
|
42
|
+
|
|
43
|
+
Raises
|
|
44
|
+
------
|
|
45
|
+
ValueError
|
|
46
|
+
If the datasource type is not supported.
|
|
47
|
+
"""
|
|
48
|
+
ds_type: str = getattr(datasource_config, "type", None) or (
|
|
49
|
+
datasource_config.get("type") if isinstance(datasource_config, dict) else None
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if ds_type is None:
|
|
53
|
+
raise ValueError("Datasource config must have a 'type' field")
|
|
54
|
+
|
|
55
|
+
dotted_path = _ADAPTER_REGISTRY.get(ds_type)
|
|
56
|
+
if dotted_path is None:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"Unsupported datasource type '{ds_type}'. "
|
|
59
|
+
f"Supported types: {', '.join(sorted(_ADAPTER_REGISTRY))}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
adapter_cls = _import_class(dotted_path)
|
|
63
|
+
|
|
64
|
+
# Build constructor kwargs from the config
|
|
65
|
+
if ds_type == "postgresql":
|
|
66
|
+
url = getattr(datasource_config, "url", None) or (
|
|
67
|
+
datasource_config.get("url") if isinstance(datasource_config, dict) else None
|
|
68
|
+
)
|
|
69
|
+
if not url:
|
|
70
|
+
raise ValueError("PostgreSQL datasource requires a 'url' field")
|
|
71
|
+
return adapter_cls(connection_url=url)
|
|
72
|
+
|
|
73
|
+
if ds_type == "duckdb":
|
|
74
|
+
path = getattr(datasource_config, "path", None) or (
|
|
75
|
+
datasource_config.get("path") if isinstance(datasource_config, dict) else None
|
|
76
|
+
)
|
|
77
|
+
seed_file = getattr(datasource_config, "seed_file", None) or (
|
|
78
|
+
datasource_config.get("seed_file") if isinstance(datasource_config, dict) else None
|
|
79
|
+
)
|
|
80
|
+
return adapter_cls(path=path or ":memory:", seed_file=seed_file)
|
|
81
|
+
|
|
82
|
+
# Generic fallback (should not be reached given the registry check above)
|
|
83
|
+
return adapter_cls()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"AdapterResult",
|
|
88
|
+
"QueryAdapter",
|
|
89
|
+
"get_adapter",
|
|
90
|
+
]
|
nexaql/adapters/base.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Copyright (c) 2026-present NexaQL Contributors
|
|
2
|
+
"""Abstract base class and shared types for database adapters."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from nexaql.engine.types import ColumnMeta, NodeShape, QueryAST
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class AdapterResult:
|
|
15
|
+
"""Result returned by a QueryAdapter after executing a query."""
|
|
16
|
+
|
|
17
|
+
rows: List[Dict[str, Any]]
|
|
18
|
+
columns: List[ColumnMeta]
|
|
19
|
+
row_count: int
|
|
20
|
+
shape: NodeShape
|
|
21
|
+
query_preview: str
|
|
22
|
+
adapter_type: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class QueryAdapter(ABC):
|
|
26
|
+
"""Interface that every database adapter must implement."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def adapter_type(self) -> str:
|
|
31
|
+
"""Short identifier for this adapter (e.g. ``'postgresql'``, ``'duckdb'``)."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def execute(self, ast: QueryAST, ontology: Any) -> AdapterResult:
|
|
36
|
+
"""Translate *ast* to SQL using *ontology*, run it, and return the result."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def describe(self, ast: QueryAST, ontology: Any) -> str:
|
|
41
|
+
"""Translate *ast* to SQL and return the SQL string without executing."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
async def healthcheck(self) -> bool:
|
|
46
|
+
"""Return ``True`` if the underlying datasource is reachable."""
|
|
47
|
+
...
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""DuckDB adapter — executes NexaQL queries against a DuckDB database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
import duckdb
|
|
10
|
+
|
|
11
|
+
from nexaql.adapters.base import AdapterResult, QueryAdapter
|
|
12
|
+
from nexaql.engine.translator import translate
|
|
13
|
+
from nexaql.engine.types import ColumnMeta, QueryAST
|
|
14
|
+
|
|
15
|
+
# DuckDB Python type name -> friendly type name
|
|
16
|
+
_DUCKDB_TYPE_MAP: Dict[str, str] = {
|
|
17
|
+
"BOOLEAN": "boolean",
|
|
18
|
+
"TINYINT": "integer",
|
|
19
|
+
"SMALLINT": "integer",
|
|
20
|
+
"INTEGER": "integer",
|
|
21
|
+
"BIGINT": "integer",
|
|
22
|
+
"HUGEINT": "integer",
|
|
23
|
+
"FLOAT": "numeric",
|
|
24
|
+
"DOUBLE": "numeric",
|
|
25
|
+
"DECIMAL": "numeric",
|
|
26
|
+
"VARCHAR": "string",
|
|
27
|
+
"DATE": "date",
|
|
28
|
+
"TIMESTAMP": "date",
|
|
29
|
+
"TIMESTAMP WITH TIME ZONE": "date",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _duckdb_type_to_str(type_name: Any) -> str:
|
|
34
|
+
# DuckDB returns DuckDBPyType objects, not plain strings — convert first
|
|
35
|
+
name = str(type_name).upper()
|
|
36
|
+
return _DUCKDB_TYPE_MAP.get(name, "string")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DuckDBAdapter(QueryAdapter):
|
|
40
|
+
"""In-process adapter for DuckDB.
|
|
41
|
+
|
|
42
|
+
Uses a persistent connection so :memory: databases retain data across queries.
|
|
43
|
+
Supports auto-seeding via a SQL file on first connect.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, path: str = ":memory:", seed_file: str | None = None) -> None:
|
|
47
|
+
self._path = path
|
|
48
|
+
self._seed_file = seed_file
|
|
49
|
+
self._conn: duckdb.DuckDBPyConnection | None = None
|
|
50
|
+
|
|
51
|
+
def _get_conn(self) -> duckdb.DuckDBPyConnection:
|
|
52
|
+
if self._conn is None:
|
|
53
|
+
self._conn = duckdb.connect(self._path)
|
|
54
|
+
self._maybe_seed()
|
|
55
|
+
return self._conn
|
|
56
|
+
|
|
57
|
+
def _maybe_seed(self) -> None:
|
|
58
|
+
"""Run the seed SQL file if the database has no tables."""
|
|
59
|
+
if not self._seed_file or not self._conn:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
# Check if tables already exist
|
|
63
|
+
tables = self._conn.execute(
|
|
64
|
+
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'main'"
|
|
65
|
+
).fetchone()
|
|
66
|
+
if tables and tables[0] > 0:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
# Resolve seed file path relative to config file location
|
|
70
|
+
seed_path = self._seed_file
|
|
71
|
+
if not os.path.isabs(seed_path):
|
|
72
|
+
# Try relative to CWD
|
|
73
|
+
if not os.path.exists(seed_path):
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
if os.path.exists(seed_path):
|
|
77
|
+
with open(seed_path) as f:
|
|
78
|
+
sql = f.read()
|
|
79
|
+
self._conn.execute(sql)
|
|
80
|
+
table_count = self._conn.execute(
|
|
81
|
+
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'main'"
|
|
82
|
+
).fetchone()
|
|
83
|
+
print(f" Seeded DuckDB with {table_count[0] if table_count else 0} tables from {seed_path}")
|
|
84
|
+
|
|
85
|
+
# -- QueryAdapter interface ------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def adapter_type(self) -> str:
|
|
89
|
+
return "duckdb"
|
|
90
|
+
|
|
91
|
+
def describe(self, ast: QueryAST, ontology: Any) -> str:
|
|
92
|
+
result = translate(ast, ontology)
|
|
93
|
+
return result.sql
|
|
94
|
+
|
|
95
|
+
async def execute(self, ast: QueryAST, ontology: Any) -> AdapterResult:
|
|
96
|
+
result = translate(ast, ontology)
|
|
97
|
+
sql = result.sql
|
|
98
|
+
|
|
99
|
+
loop = asyncio.get_running_loop()
|
|
100
|
+
rows, columns = await loop.run_in_executor(None, self._run_query, sql)
|
|
101
|
+
|
|
102
|
+
return AdapterResult(
|
|
103
|
+
rows=rows,
|
|
104
|
+
columns=columns,
|
|
105
|
+
row_count=len(rows),
|
|
106
|
+
shape=result.shape,
|
|
107
|
+
query_preview=sql,
|
|
108
|
+
adapter_type=self.adapter_type,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def healthcheck(self) -> bool:
|
|
112
|
+
try:
|
|
113
|
+
loop = asyncio.get_running_loop()
|
|
114
|
+
await loop.run_in_executor(None, self._ping)
|
|
115
|
+
return True
|
|
116
|
+
except Exception:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
# -- internal helpers ------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _run_query(self, sql: str) -> tuple[List[Dict[str, Any]], List[ColumnMeta]]:
|
|
122
|
+
conn = self._get_conn()
|
|
123
|
+
rel = conn.execute(sql)
|
|
124
|
+
description = rel.description
|
|
125
|
+
raw_rows = rel.fetchall()
|
|
126
|
+
|
|
127
|
+
columns: List[ColumnMeta] = []
|
|
128
|
+
col_names: List[str] = []
|
|
129
|
+
for col_name, col_type, *_ in description:
|
|
130
|
+
columns.append(ColumnMeta(name=col_name, type=_duckdb_type_to_str(col_type)))
|
|
131
|
+
col_names.append(col_name)
|
|
132
|
+
|
|
133
|
+
rows: List[Dict[str, Any]] = [dict(zip(col_names, row)) for row in raw_rows]
|
|
134
|
+
return rows, columns
|
|
135
|
+
|
|
136
|
+
def _ping(self) -> None:
|
|
137
|
+
conn = self._get_conn()
|
|
138
|
+
conn.execute("SELECT 1").fetchone()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Copyright (c) 2026-present NexaQL Contributors
|
|
2
|
+
"""PostgreSQL adapter — executes NexaQL queries against a PostgreSQL database."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
import asyncpg
|
|
10
|
+
|
|
11
|
+
from nexaql.adapters.base import AdapterResult, QueryAdapter
|
|
12
|
+
from nexaql.engine.translator import translate
|
|
13
|
+
from nexaql.engine.types import ColumnMeta, QueryAST
|
|
14
|
+
|
|
15
|
+
# asyncpg OID -> friendly type name (covers the most common types)
|
|
16
|
+
_PG_TYPE_MAP: Dict[int, str] = {
|
|
17
|
+
16: "boolean",
|
|
18
|
+
20: "integer",
|
|
19
|
+
21: "integer",
|
|
20
|
+
23: "integer",
|
|
21
|
+
25: "string",
|
|
22
|
+
700: "numeric",
|
|
23
|
+
701: "numeric",
|
|
24
|
+
1042: "string",
|
|
25
|
+
1043: "string",
|
|
26
|
+
1082: "date",
|
|
27
|
+
1114: "date",
|
|
28
|
+
1184: "date",
|
|
29
|
+
1700: "numeric",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pg_oid_to_type(oid: int) -> str:
|
|
34
|
+
return _PG_TYPE_MAP.get(oid, "string")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PostgreSQLAdapter(QueryAdapter):
|
|
38
|
+
"""Async adapter backed by an ``asyncpg`` connection pool."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, connection_url: str) -> None:
|
|
41
|
+
self._url = connection_url
|
|
42
|
+
self._pool: Optional[asyncpg.Pool] = None
|
|
43
|
+
self._lock = asyncio.Lock()
|
|
44
|
+
|
|
45
|
+
# -- pool management -------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
async def _get_pool(self) -> asyncpg.Pool:
|
|
48
|
+
if self._pool is None or self._pool._closed:
|
|
49
|
+
async with self._lock:
|
|
50
|
+
if self._pool is None or self._pool._closed:
|
|
51
|
+
self._pool = await asyncpg.create_pool(self._url, min_size=1, max_size=5)
|
|
52
|
+
return self._pool
|
|
53
|
+
|
|
54
|
+
async def close(self) -> None:
|
|
55
|
+
if self._pool is not None and not self._pool._closed:
|
|
56
|
+
await self._pool.close()
|
|
57
|
+
self._pool = None
|
|
58
|
+
|
|
59
|
+
# -- QueryAdapter interface ------------------------------------------------
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def adapter_type(self) -> str:
|
|
63
|
+
return "postgresql"
|
|
64
|
+
|
|
65
|
+
def describe(self, ast: QueryAST, ontology: Any) -> str:
|
|
66
|
+
result = translate(ast, ontology)
|
|
67
|
+
return result.sql
|
|
68
|
+
|
|
69
|
+
async def execute(self, ast: QueryAST, ontology: Any) -> AdapterResult:
|
|
70
|
+
result = translate(ast, ontology)
|
|
71
|
+
sql = result.sql
|
|
72
|
+
|
|
73
|
+
pool = await self._get_pool()
|
|
74
|
+
async with pool.acquire() as conn:
|
|
75
|
+
stmt = await conn.prepare(sql)
|
|
76
|
+
records = await stmt.fetch()
|
|
77
|
+
|
|
78
|
+
# Infer column metadata from the prepared statement attributes
|
|
79
|
+
columns: List[ColumnMeta] = []
|
|
80
|
+
for attr in stmt.get_attributes():
|
|
81
|
+
columns.append(
|
|
82
|
+
ColumnMeta(
|
|
83
|
+
name=attr.name,
|
|
84
|
+
type=_pg_oid_to_type(attr.type.oid),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
rows: List[Dict[str, Any]] = [dict(r) for r in records]
|
|
89
|
+
|
|
90
|
+
return AdapterResult(
|
|
91
|
+
rows=rows,
|
|
92
|
+
columns=columns,
|
|
93
|
+
row_count=len(rows),
|
|
94
|
+
shape=result.shape,
|
|
95
|
+
query_preview=sql,
|
|
96
|
+
adapter_type=self.adapter_type,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def healthcheck(self) -> bool:
|
|
100
|
+
try:
|
|
101
|
+
pool = await self._get_pool()
|
|
102
|
+
async with pool.acquire() as conn:
|
|
103
|
+
await conn.fetchval("SELECT 1")
|
|
104
|
+
return True
|
|
105
|
+
except Exception:
|
|
106
|
+
return False
|
nexaql/api/__init__.py
ADDED
nexaql/api/app.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Copyright (c) 2026-present NexaQL Contributors
|
|
2
|
+
"""FastAPI application factory for NexaQL."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
11
|
+
from fastapi.responses import JSONResponse
|
|
12
|
+
from fastapi.staticfiles import StaticFiles
|
|
13
|
+
|
|
14
|
+
from nexaql.api.deps import get_config
|
|
15
|
+
from nexaql.api.routes import chat, execute, ontology, suggest, validate
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_app() -> FastAPI:
|
|
19
|
+
"""Build and return the FastAPI application instance."""
|
|
20
|
+
cfg = get_config()
|
|
21
|
+
|
|
22
|
+
app = FastAPI(
|
|
23
|
+
title="NexaQL",
|
|
24
|
+
description="A GraphQL-inspired query language for any structured data",
|
|
25
|
+
version="0.1.0",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# ── CORS ────────────────────────────────────────────────────────────────
|
|
29
|
+
app.add_middleware(
|
|
30
|
+
CORSMiddleware,
|
|
31
|
+
allow_origins=cfg.server.cors_origins,
|
|
32
|
+
allow_credentials=True,
|
|
33
|
+
allow_methods=["*"],
|
|
34
|
+
allow_headers=["*"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# ── API routes ──────────────────────────────────────────────────────────
|
|
38
|
+
app.include_router(execute.router, prefix="/api")
|
|
39
|
+
app.include_router(validate.router, prefix="/api")
|
|
40
|
+
app.include_router(ontology.router, prefix="/api")
|
|
41
|
+
app.include_router(suggest.router, prefix="/api")
|
|
42
|
+
app.include_router(chat.router, prefix="/api")
|
|
43
|
+
|
|
44
|
+
# ── Health check ────────────────────────────────────────────────────────
|
|
45
|
+
@app.get("/api/health")
|
|
46
|
+
async def health() -> JSONResponse:
|
|
47
|
+
return JSONResponse({"status": "ok"})
|
|
48
|
+
|
|
49
|
+
# ── Serve frontend static files ───────────────────────────────────────
|
|
50
|
+
# Check bundled static dir first (pip install), then local frontend/dist (dev)
|
|
51
|
+
bundled_static = Path(__file__).parent.parent / "static"
|
|
52
|
+
local_dist = Path(os.getcwd()) / "frontend" / "dist"
|
|
53
|
+
|
|
54
|
+
static_dir = bundled_static if bundled_static.is_dir() else local_dist
|
|
55
|
+
if static_dir.is_dir():
|
|
56
|
+
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="frontend")
|
|
57
|
+
|
|
58
|
+
return app
|
nexaql/api/deps.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Copyright (c) 2026-present NexaQL Contributors
|
|
2
|
+
"""FastAPI dependencies for NexaQL API routes."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from nexaql.adapters import get_adapter as _get_adapter
|
|
11
|
+
from nexaql.adapters.base import QueryAdapter
|
|
12
|
+
from nexaql.config import NexaQLConfig, load_config
|
|
13
|
+
from nexaql.ontology import Ontology, load_ontology
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── Cached config loader ────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@lru_cache(maxsize=1)
|
|
20
|
+
def get_config() -> NexaQLConfig:
|
|
21
|
+
"""Load and cache the ``nexaql.yaml`` configuration.
|
|
22
|
+
|
|
23
|
+
The config path is resolved from the ``NEXAQL_CONFIG`` environment
|
|
24
|
+
variable, falling back to ``nexaql.yaml`` in the current directory.
|
|
25
|
+
"""
|
|
26
|
+
path = os.environ.get("NEXAQL_CONFIG", "nexaql.yaml")
|
|
27
|
+
return load_config(path)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Cached ontology loader ──────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_ontology() -> Ontology:
|
|
34
|
+
"""Load the ontology specified in the config.
|
|
35
|
+
|
|
36
|
+
Uses the file-mtime cache built into :func:`nexaql.ontology.load_ontology`
|
|
37
|
+
so the ontology is automatically reloaded when the YAML file changes.
|
|
38
|
+
"""
|
|
39
|
+
cfg = get_config()
|
|
40
|
+
return load_ontology(cfg.ontology.path)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── Adapter factory ─────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_adapter_cache: dict[str, QueryAdapter] = {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_adapter_for_datasource(datasource_name: str | None = None) -> QueryAdapter:
|
|
50
|
+
"""Get or create a :class:`QueryAdapter` from the config's datasource entry.
|
|
51
|
+
|
|
52
|
+
Adapters are cached so in-memory DuckDB databases retain seeded data.
|
|
53
|
+
If *datasource_name* is ``None`` the first datasource in the config is used.
|
|
54
|
+
"""
|
|
55
|
+
cfg = get_config()
|
|
56
|
+
|
|
57
|
+
if not cfg.datasources:
|
|
58
|
+
raise ValueError("No datasources configured in nexaql.yaml")
|
|
59
|
+
|
|
60
|
+
if datasource_name is None:
|
|
61
|
+
datasource_name = next(iter(cfg.datasources))
|
|
62
|
+
|
|
63
|
+
if datasource_name in _adapter_cache:
|
|
64
|
+
return _adapter_cache[datasource_name]
|
|
65
|
+
|
|
66
|
+
ds_entry = cfg.datasources.get(datasource_name)
|
|
67
|
+
if ds_entry is None:
|
|
68
|
+
available = ", ".join(cfg.datasources.keys())
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Datasource '{datasource_name}' not found. Available: {available}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
adapter = _get_adapter(ds_entry)
|
|
74
|
+
_adapter_cache[datasource_name] = adapter
|
|
75
|
+
return adapter
|
nexaql/api/middleware.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Copyright (c) 2026-present NexaQL Contributors
|
|
2
|
+
"""FastAPI dependency that extracts UserContext from the request."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
|
|
11
|
+
from nexaql.policy.context import ANONYMOUS, UserContext
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def get_user_context(request: Request) -> UserContext:
|
|
17
|
+
"""Extract a :class:`UserContext` from the incoming request.
|
|
18
|
+
|
|
19
|
+
Dev mode (default):
|
|
20
|
+
- Check the ``X-User-Context`` header. If present, parse it as JSON
|
|
21
|
+
into a :class:`UserContext`.
|
|
22
|
+
- If the header is absent, return :data:`ANONYMOUS` (full access,
|
|
23
|
+
``roles=["*"]``).
|
|
24
|
+
|
|
25
|
+
Malformed JSON is handled gracefully: a warning is logged and
|
|
26
|
+
:data:`ANONYMOUS` is returned so the request is not blocked by a
|
|
27
|
+
bad header.
|
|
28
|
+
"""
|
|
29
|
+
header_value = request.headers.get("X-User-Context")
|
|
30
|
+
if not header_value:
|
|
31
|
+
return ANONYMOUS
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(header_value)
|
|
35
|
+
return UserContext(
|
|
36
|
+
user_id=data.get("user_id", "anonymous"),
|
|
37
|
+
roles=data.get("roles", []),
|
|
38
|
+
attributes=data.get("attributes", {}),
|
|
39
|
+
)
|
|
40
|
+
except (json.JSONDecodeError, TypeError, KeyError) as exc:
|
|
41
|
+
logger.warning(
|
|
42
|
+
"Malformed X-User-Context header, falling back to ANONYMOUS: %s", exc
|
|
43
|
+
)
|
|
44
|
+
return ANONYMOUS
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Copyright (c) 2026-present NexaQL Contributors
|
|
2
|
+
"""POST /api/chat -- NL -> NexaQL -> execute -> summarize pipeline."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from nexaql.adapters import get_adapter
|
|
12
|
+
from nexaql.api.deps import get_config, get_ontology
|
|
13
|
+
from nexaql.chat.agent import ChatResponse, ask
|
|
14
|
+
|
|
15
|
+
router = APIRouter()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ChatMessage(BaseModel):
|
|
19
|
+
role: str # "user" | "assistant"
|
|
20
|
+
content: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ChatRequest(BaseModel):
|
|
24
|
+
question: str
|
|
25
|
+
history: list[ChatMessage] = []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ChatResponseBody(BaseModel):
|
|
29
|
+
explanation: str | None = None
|
|
30
|
+
nexaqlQuery: str | None = None
|
|
31
|
+
queryPreview: str | None = None
|
|
32
|
+
adapterType: str | None = None
|
|
33
|
+
rows: list[dict[str, Any]] = []
|
|
34
|
+
columns: list[dict[str, str]] = []
|
|
35
|
+
rowCount: int = 0
|
|
36
|
+
shape: Any | None = None
|
|
37
|
+
summary: str | None = None
|
|
38
|
+
error: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.post("/chat")
|
|
42
|
+
async def chat_endpoint(body: ChatRequest) -> ChatResponseBody:
|
|
43
|
+
if not body.question.strip():
|
|
44
|
+
raise HTTPException(status_code=400, detail="Question is required")
|
|
45
|
+
|
|
46
|
+
cfg = get_config()
|
|
47
|
+
|
|
48
|
+
# Check LLM API key before doing anything
|
|
49
|
+
if not cfg.llm.api_key:
|
|
50
|
+
return ChatResponseBody(
|
|
51
|
+
error=(
|
|
52
|
+
"Agent Chat requires an LLM API key. "
|
|
53
|
+
"Set ANTHROPIC_API_KEY in your environment, or add it to nexaql.yaml under llm.api_key."
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
ontology = get_ontology()
|
|
58
|
+
|
|
59
|
+
# Resolve the default adapter
|
|
60
|
+
try:
|
|
61
|
+
if cfg.datasources:
|
|
62
|
+
adapter = get_adapter(next(iter(cfg.datasources.values())))
|
|
63
|
+
else:
|
|
64
|
+
adapter = None
|
|
65
|
+
except Exception:
|
|
66
|
+
adapter = None
|
|
67
|
+
|
|
68
|
+
history = [{"role": m.role, "content": m.content} for m in body.history]
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
result: ChatResponse = await ask(
|
|
72
|
+
question=body.question,
|
|
73
|
+
history=history,
|
|
74
|
+
ontology=ontology,
|
|
75
|
+
adapter=adapter,
|
|
76
|
+
llm_config=cfg.llm,
|
|
77
|
+
)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
return ChatResponseBody(error=str(e))
|
|
80
|
+
|
|
81
|
+
return ChatResponseBody(
|
|
82
|
+
explanation=result.explanation,
|
|
83
|
+
nexaqlQuery=result.nexaql_query,
|
|
84
|
+
queryPreview=result.query_preview,
|
|
85
|
+
adapterType=result.adapter_type,
|
|
86
|
+
rows=result.rows,
|
|
87
|
+
columns=[{"name": c.name, "type": c.type} for c in result.columns] if result.columns else [],
|
|
88
|
+
rowCount=result.row_count,
|
|
89
|
+
shape=result.shape,
|
|
90
|
+
summary=result.summary,
|
|
91
|
+
error=result.error,
|
|
92
|
+
)
|