nl2sql-engine 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.
- nl2sql/__init__.py +38 -0
- nl2sql/adapters/__init__.py +0 -0
- nl2sql/adapters/duckdb/__init__.py +0 -0
- nl2sql/adapters/duckdb/adapter.py +71 -0
- nl2sql/adapters/mssql/__init__.py +0 -0
- nl2sql/adapters/mssql/adapter.py +122 -0
- nl2sql/adapters/mysql/__init__.py +0 -0
- nl2sql/adapters/mysql/adapter.py +123 -0
- nl2sql/adapters/postgres/__init__.py +0 -0
- nl2sql/adapters/postgres/adapter.py +115 -0
- nl2sql/adapters/sqlalchemy_base/__init__.py +17 -0
- nl2sql/adapters/sqlalchemy_base/adapter.py +476 -0
- nl2sql/adapters/sqlalchemy_base/models.py +36 -0
- nl2sql/adapters/sqlite/__init__.py +0 -0
- nl2sql/adapters/sqlite/adapter.py +88 -0
- nl2sql/aggregation/__init__.py +3 -0
- nl2sql/aggregation/aggregator.py +98 -0
- nl2sql/aggregation/engines/__init__.py +3 -0
- nl2sql/aggregation/engines/polars_duckdb.py +125 -0
- nl2sql/api/__init__.py +0 -0
- nl2sql/api/auth_api.py +60 -0
- nl2sql/api/benchmark_api.py +114 -0
- nl2sql/api/datasource_api.py +132 -0
- nl2sql/api/indexing_api.py +59 -0
- nl2sql/api/llm_api.py +82 -0
- nl2sql/api/policy_api.py +135 -0
- nl2sql/api/query_api.py +138 -0
- nl2sql/api/result_api.py +24 -0
- nl2sql/api/settings_api.py +65 -0
- nl2sql/auth/__init__.py +8 -0
- nl2sql/auth/models.py +36 -0
- nl2sql/auth/rbac.py +25 -0
- nl2sql/cli/__init__.py +0 -0
- nl2sql/cli/checks.py +53 -0
- nl2sql/cli/commands/__init__.py +0 -0
- nl2sql/cli/commands/benchmark.py +34 -0
- nl2sql/cli/commands/doctor.py +49 -0
- nl2sql/cli/commands/indexing.py +126 -0
- nl2sql/cli/commands/info.py +25 -0
- nl2sql/cli/commands/install.py +27 -0
- nl2sql/cli/commands/policy.py +57 -0
- nl2sql/cli/commands/run.py +166 -0
- nl2sql/cli/commands/setup.py +415 -0
- nl2sql/cli/commands/visualize.py +34 -0
- nl2sql/cli/common/decorators.py +34 -0
- nl2sql/cli/config.py +24 -0
- nl2sql/cli/console.py +52 -0
- nl2sql/cli/demo/__init__.py +1 -0
- nl2sql/cli/demo/data.py +87 -0
- nl2sql/cli/demo/defaults.py +122 -0
- nl2sql/cli/demo/factory.py +289 -0
- nl2sql/cli/demo/manager.py +230 -0
- nl2sql/cli/demo/schemas.py +336 -0
- nl2sql/cli/demo/writers/__init__.py +0 -0
- nl2sql/cli/demo/writers/docker.py +182 -0
- nl2sql/cli/demo/writers/sqlite.py +88 -0
- nl2sql/cli/generators/datasources/__init__.py +3 -0
- nl2sql/cli/generators/datasources/generator.py +24 -0
- nl2sql/cli/generators/datasources/templates.py +7 -0
- nl2sql/cli/generators/env/__init__.py +3 -0
- nl2sql/cli/generators/env/generator.py +46 -0
- nl2sql/cli/generators/env/templates.py +25 -0
- nl2sql/cli/generators/llm/__init__.py +3 -0
- nl2sql/cli/generators/llm/generator.py +24 -0
- nl2sql/cli/generators/llm/templates.py +4 -0
- nl2sql/cli/generators/policies/__init__.py +3 -0
- nl2sql/cli/generators/policies/generator.py +20 -0
- nl2sql/cli/generators/policies/templates.py +2 -0
- nl2sql/cli/main.py +195 -0
- nl2sql/cli/reporting.py +878 -0
- nl2sql/cli/types.py +13 -0
- nl2sql/common/__init__.py +1 -0
- nl2sql/common/cancellation.py +25 -0
- nl2sql/common/context.py +5 -0
- nl2sql/common/errors.py +109 -0
- nl2sql/common/event_logger.py +88 -0
- nl2sql/common/exceptions.py +3 -0
- nl2sql/common/logger.py +119 -0
- nl2sql/common/metrics.py +50 -0
- nl2sql/common/resilience.py +59 -0
- nl2sql/common/settings.py +195 -0
- nl2sql/configs/__init__.py +6 -0
- nl2sql/configs/datasources.py +10 -0
- nl2sql/configs/llm.py +36 -0
- nl2sql/configs/manager.py +176 -0
- nl2sql/configs/policies.py +14 -0
- nl2sql/configs/sample_questions.py +11 -0
- nl2sql/configs/secrets.py +11 -0
- nl2sql/context.py +106 -0
- nl2sql/datasources/__init__.py +21 -0
- nl2sql/datasources/discovery.py +28 -0
- nl2sql/datasources/models.py +21 -0
- nl2sql/datasources/protocols.py +3 -0
- nl2sql/datasources/registry.py +172 -0
- nl2sql/evaluation/__init__.py +6 -0
- nl2sql/evaluation/benchmark_runner.py +320 -0
- nl2sql/evaluation/evaluator.py +134 -0
- nl2sql/evaluation/types.py +22 -0
- nl2sql/execution/__init__.py +4 -0
- nl2sql/execution/artifacts/__init__.py +3 -0
- nl2sql/execution/artifacts/parquet.py +41 -0
- nl2sql/execution/artifacts/store.py +165 -0
- nl2sql/execution/contracts.py +57 -0
- nl2sql/execution/execution_store.py +25 -0
- nl2sql/execution/executor/__init__.py +3 -0
- nl2sql/execution/executor/sql_executor.py +116 -0
- nl2sql/indexing/__init__.py +7 -0
- nl2sql/indexing/chunk_builder.py +227 -0
- nl2sql/indexing/embeddings.py +180 -0
- nl2sql/indexing/enrichment_service.py +316 -0
- nl2sql/indexing/models.py +209 -0
- nl2sql/indexing/orchestrator.py +90 -0
- nl2sql/indexing/vector_store.py +422 -0
- nl2sql/llm/__init__.py +8 -0
- nl2sql/llm/models.py +10 -0
- nl2sql/llm/registry.py +214 -0
- nl2sql/pipeline/__init__.py +1 -0
- nl2sql/pipeline/graph.py +73 -0
- nl2sql/pipeline/graph_utils.py +141 -0
- nl2sql/pipeline/nodes/__init__.py +25 -0
- nl2sql/pipeline/nodes/aggregator/__init__.py +4 -0
- nl2sql/pipeline/nodes/aggregator/node.py +55 -0
- nl2sql/pipeline/nodes/aggregator/prompts.py +20 -0
- nl2sql/pipeline/nodes/aggregator/schemas.py +28 -0
- nl2sql/pipeline/nodes/answer_synthesizer/__init__.py +4 -0
- nl2sql/pipeline/nodes/answer_synthesizer/node.py +98 -0
- nl2sql/pipeline/nodes/answer_synthesizer/prompts.py +19 -0
- nl2sql/pipeline/nodes/answer_synthesizer/schemas.py +24 -0
- nl2sql/pipeline/nodes/ast_planner/__init__.py +4 -0
- nl2sql/pipeline/nodes/ast_planner/node.py +104 -0
- nl2sql/pipeline/nodes/ast_planner/prompts.py +138 -0
- nl2sql/pipeline/nodes/ast_planner/schemas.py +236 -0
- nl2sql/pipeline/nodes/datasource_resolver/__init__.py +4 -0
- nl2sql/pipeline/nodes/datasource_resolver/node.py +253 -0
- nl2sql/pipeline/nodes/datasource_resolver/schemas.py +21 -0
- nl2sql/pipeline/nodes/decomposer/__init__.py +3 -0
- nl2sql/pipeline/nodes/decomposer/node.py +219 -0
- nl2sql/pipeline/nodes/decomposer/prompts.py +96 -0
- nl2sql/pipeline/nodes/decomposer/schemas.py +143 -0
- nl2sql/pipeline/nodes/executor/__init__.py +3 -0
- nl2sql/pipeline/nodes/executor/node.py +107 -0
- nl2sql/pipeline/nodes/generator/__init__.py +4 -0
- nl2sql/pipeline/nodes/generator/node.py +267 -0
- nl2sql/pipeline/nodes/generator/schemas.py +13 -0
- nl2sql/pipeline/nodes/global_planner/__init__.py +4 -0
- nl2sql/pipeline/nodes/global_planner/node.py +186 -0
- nl2sql/pipeline/nodes/global_planner/schemas.py +101 -0
- nl2sql/pipeline/nodes/refiner/__init__.py +4 -0
- nl2sql/pipeline/nodes/refiner/node.py +132 -0
- nl2sql/pipeline/nodes/refiner/prompts.py +28 -0
- nl2sql/pipeline/nodes/refiner/schemas.py +13 -0
- nl2sql/pipeline/nodes/schema_retriever/__init__.py +3 -0
- nl2sql/pipeline/nodes/schema_retriever/node.py +252 -0
- nl2sql/pipeline/nodes/schema_retriever/schema.py +27 -0
- nl2sql/pipeline/nodes/validator/__init__.py +7 -0
- nl2sql/pipeline/nodes/validator/node.py +839 -0
- nl2sql/pipeline/nodes/validator/schemas.py +12 -0
- nl2sql/pipeline/pipeline_runner.py +72 -0
- nl2sql/pipeline/routes.py +72 -0
- nl2sql/pipeline/runtime.py +153 -0
- nl2sql/pipeline/state.py +92 -0
- nl2sql/pipeline/subgraphs/__init__.py +5 -0
- nl2sql/pipeline/subgraphs/schemas.py +23 -0
- nl2sql/pipeline/subgraphs/sql_agent.py +167 -0
- nl2sql/public_api.py +199 -0
- nl2sql/schema/__init__.py +37 -0
- nl2sql/schema/in_memory_store.py +173 -0
- nl2sql/schema/protocol.py +88 -0
- nl2sql/schema/sqlite_store.py +233 -0
- nl2sql/schema/store.py +29 -0
- nl2sql/secrets/__init__.py +14 -0
- nl2sql/secrets/factory.py +85 -0
- nl2sql/secrets/interfaces.py +16 -0
- nl2sql/secrets/manager.py +139 -0
- nl2sql/secrets/models.py +56 -0
- nl2sql/secrets/providers/aws.py +30 -0
- nl2sql/secrets/providers/azure.py +49 -0
- nl2sql/secrets/providers/env.py +8 -0
- nl2sql/secrets/providers/hashi.py +46 -0
- nl2sql/services/__init__.py +0 -0
- nl2sql/services/callbacks/__init__.py +0 -0
- nl2sql/services/callbacks/monitor.py +84 -0
- nl2sql/services/callbacks/node_context.py +7 -0
- nl2sql/services/callbacks/node_handlers.py +187 -0
- nl2sql/services/callbacks/node_metrics.py +14 -0
- nl2sql/services/callbacks/presenter.py +12 -0
- nl2sql/services/callbacks/token_handler.py +56 -0
- nl2sql_engine-0.1.0.dist-info/METADATA +295 -0
- nl2sql_engine-0.1.0.dist-info/RECORD +192 -0
- nl2sql_engine-0.1.0.dist-info/WHEEL +5 -0
- nl2sql_engine-0.1.0.dist-info/entry_points.txt +9 -0
- nl2sql_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
from typing import Dict, Any, List
|
|
2
|
+
from sqlalchemy import create_engine, inspect, text, Engine, select, func, table, column, case, literal_column, Connection
|
|
3
|
+
from sqlalchemy.engine.reflection import Inspector
|
|
4
|
+
from typing import Tuple
|
|
5
|
+
from nl2sql_adapter_sdk.capabilities import DatasourceCapability
|
|
6
|
+
from nl2sql_adapter_sdk.contracts import (
|
|
7
|
+
AdapterRequest,
|
|
8
|
+
ResultError,
|
|
9
|
+
ResultFrame,
|
|
10
|
+
)
|
|
11
|
+
from .models import DryRunResult, QueryPlan, CostEstimate
|
|
12
|
+
from nl2sql_adapter_sdk.schema import (
|
|
13
|
+
SchemaContract,
|
|
14
|
+
SchemaMetadata,
|
|
15
|
+
TableContract,
|
|
16
|
+
ColumnContract,
|
|
17
|
+
ForeignKeyContract,
|
|
18
|
+
ColumnMetadata,
|
|
19
|
+
TableMetadata,
|
|
20
|
+
SchemaSnapshot,
|
|
21
|
+
ColumnStatistics,
|
|
22
|
+
TableRef,
|
|
23
|
+
)
|
|
24
|
+
import logging
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
class BaseSQLAlchemyAdapter:
|
|
28
|
+
"""
|
|
29
|
+
Base class for all SQLAlchemy-based adapters.
|
|
30
|
+
Implements common logic for connection, execution, and schema fetching.
|
|
31
|
+
"""
|
|
32
|
+
def __init__(self, datasource_id: str = None, datasource_engine_type: str = None, connection_args: Dict[str, Any] = None, **kwargs):
|
|
33
|
+
"""Initializes the SQLAlchemy adapter.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
datasource_id (str, optional): The unique identifier for the datasource.
|
|
37
|
+
datasource_engine_type (str, optional): The engine type (e.g., 'postgres').
|
|
38
|
+
connection_args (Dict[str, Any], optional): The resolved connection arguments.
|
|
39
|
+
**kwargs: Additional configuration parameters like 'row_limit' and 'max_bytes'.
|
|
40
|
+
"""
|
|
41
|
+
self._datasource_id = datasource_id
|
|
42
|
+
self.datasource_engine_type = datasource_engine_type
|
|
43
|
+
self._row_limit = kwargs.get("row_limit")
|
|
44
|
+
self._max_bytes = kwargs.get("max_bytes")
|
|
45
|
+
|
|
46
|
+
self.statement_timeout_ms = kwargs.get("statement_timeout_ms")
|
|
47
|
+
self.execution_options = {}
|
|
48
|
+
|
|
49
|
+
if self.statement_timeout_ms:
|
|
50
|
+
self.execution_options["timeout"] = self.statement_timeout_ms / 1000.0
|
|
51
|
+
|
|
52
|
+
self.connection_args = connection_args
|
|
53
|
+
self.connection_string = self.construct_uri(connection_args)
|
|
54
|
+
|
|
55
|
+
self.engine: Engine = None
|
|
56
|
+
if self.connection_string:
|
|
57
|
+
self.connect()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def datasource_id(self) -> str:
|
|
61
|
+
return self._datasource_id
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def row_limit(self) -> int:
|
|
65
|
+
return self._row_limit
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def max_bytes(self) -> int:
|
|
69
|
+
return self._max_bytes
|
|
70
|
+
|
|
71
|
+
def __str__(self):
|
|
72
|
+
return f"{self.datasource_id} ({self.datasource_engine_type})"
|
|
73
|
+
|
|
74
|
+
def construct_uri(self, args: Dict[str, Any]) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Constructs a SQLAlchemy URL from a connection dictionary.
|
|
77
|
+
Must be implemented by subclasses.
|
|
78
|
+
"""
|
|
79
|
+
raise NotImplementedError(f"Adapter {self.__class__.__name__} must implement construct_uri")
|
|
80
|
+
|
|
81
|
+
def connect(self) -> None:
|
|
82
|
+
"""Establishes a connection to the database.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ValueError: If the connection string is missing.
|
|
86
|
+
Exception: If connection fails.
|
|
87
|
+
"""
|
|
88
|
+
conn_str = self.connection_string
|
|
89
|
+
if not conn_str:
|
|
90
|
+
raise ValueError(f"Connection string is required for {self}")
|
|
91
|
+
try:
|
|
92
|
+
self.engine = create_engine(
|
|
93
|
+
conn_str,
|
|
94
|
+
pool_pre_ping=True,
|
|
95
|
+
execution_options=self.execution_options
|
|
96
|
+
)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.error(f"Failed to connect to database: {e}")
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def capabilities(self) -> set[DatasourceCapability]:
|
|
103
|
+
"""Default capability set for SQL adapters."""
|
|
104
|
+
return {
|
|
105
|
+
DatasourceCapability.SUPPORTS_SQL,
|
|
106
|
+
DatasourceCapability.SUPPORTS_SCHEMA_INTROSPECTION,
|
|
107
|
+
DatasourceCapability.SUPPORTS_DRY_RUN,
|
|
108
|
+
DatasourceCapability.SUPPORTS_COST_ESTIMATE,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
def execute_sql(self, sql: str) -> ResultFrame:
|
|
112
|
+
"""Executes a SQL query against the datasource.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
sql (str): The SQL query string to execute.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
ResultFrame: The results of the query execution.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
RuntimeError: If the adapter is not connected.
|
|
122
|
+
"""
|
|
123
|
+
if not self.engine:
|
|
124
|
+
raise RuntimeError(f"Not connected to {self}")
|
|
125
|
+
|
|
126
|
+
import time
|
|
127
|
+
start = time.perf_counter()
|
|
128
|
+
|
|
129
|
+
with self.engine.connect() as conn:
|
|
130
|
+
result = conn.execute(text(sql))
|
|
131
|
+
if result.returns_rows:
|
|
132
|
+
rows = [list(row) for row in result.fetchall()]
|
|
133
|
+
cols = list(result.keys())
|
|
134
|
+
row_count = len(rows)
|
|
135
|
+
else:
|
|
136
|
+
rows = []
|
|
137
|
+
cols = []
|
|
138
|
+
row_count = result.rowcount
|
|
139
|
+
|
|
140
|
+
duration = time.perf_counter() - start
|
|
141
|
+
|
|
142
|
+
total_bytes = 0
|
|
143
|
+
if row_count > 0:
|
|
144
|
+
sample_size = min(50, row_count)
|
|
145
|
+
sample_bytes = 0
|
|
146
|
+
for i in range(sample_size):
|
|
147
|
+
row = rows[i]
|
|
148
|
+
for item in row:
|
|
149
|
+
if item is not None:
|
|
150
|
+
sample_bytes += len(str(item))
|
|
151
|
+
|
|
152
|
+
avg_row_bytes = sample_bytes / sample_size
|
|
153
|
+
total_bytes = int(avg_row_bytes * row_count)
|
|
154
|
+
|
|
155
|
+
return ResultFrame(
|
|
156
|
+
success=True,
|
|
157
|
+
columns=cols,
|
|
158
|
+
rows=rows,
|
|
159
|
+
row_count=row_count,
|
|
160
|
+
bytes=total_bytes,
|
|
161
|
+
datasource_id=self.datasource_id,
|
|
162
|
+
execution_stats={"execution_time_ms": duration * 1000},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def execute(self, request: AdapterRequest) -> ResultFrame:
|
|
166
|
+
"""Executes an adapter request and returns a ResultFrame."""
|
|
167
|
+
if request.plan_type.lower() != "sql":
|
|
168
|
+
return ResultFrame(
|
|
169
|
+
success=False,
|
|
170
|
+
datasource_id=self.datasource_id,
|
|
171
|
+
error=ResultError(
|
|
172
|
+
error_code="CAPABILITY_VIOLATION",
|
|
173
|
+
safe_message="SQL adapter received non-SQL request.",
|
|
174
|
+
severity="ERROR",
|
|
175
|
+
retryable=False,
|
|
176
|
+
stage="adapter",
|
|
177
|
+
datasource_id=self.datasource_id,
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
sql = request.payload.get("sql")
|
|
182
|
+
if not sql:
|
|
183
|
+
return ResultFrame(
|
|
184
|
+
success=False,
|
|
185
|
+
datasource_id=self.datasource_id,
|
|
186
|
+
error=ResultError(
|
|
187
|
+
error_code="MISSING_SQL",
|
|
188
|
+
safe_message="SQL adapter received an empty SQL payload.",
|
|
189
|
+
severity="ERROR",
|
|
190
|
+
retryable=False,
|
|
191
|
+
stage="adapter",
|
|
192
|
+
datasource_id=self.datasource_id,
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return self.execute_sql(sql)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def exclude_schemas(self) -> set[str]:
|
|
201
|
+
raise NotImplementedError(f"Adapter {self.__class__.__name__} must implement exclude_schemas")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def fetch_schema_contact(self) -> SchemaContract:
|
|
205
|
+
"""Fetches the schema contract for the connected datasource.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
SchemaContract: The contract containing tables, columns, and relationships.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
RuntimeError: If the adapter is not connected.
|
|
212
|
+
"""
|
|
213
|
+
if not self.engine:
|
|
214
|
+
raise RuntimeError(f"Not connected to {self}. Please verify the connection details.")
|
|
215
|
+
|
|
216
|
+
exclude_schemas = {schema.lower() for schema in self.exclude_schemas}
|
|
217
|
+
inspector = inspect(self.engine)
|
|
218
|
+
tables_contract = {}
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
schemas = [schema for schema in inspector.get_schema_names() if schema.lower() not in exclude_schemas]
|
|
222
|
+
except Exception as e:
|
|
223
|
+
logger.error(f"Failed to fetch table names for {self}: {e}")
|
|
224
|
+
raise
|
|
225
|
+
|
|
226
|
+
with self.engine.connect() as conn:
|
|
227
|
+
for schema in sorted(schemas):
|
|
228
|
+
table_names = inspector.get_table_names(schema=schema)
|
|
229
|
+
for table_name in sorted(table_names):
|
|
230
|
+
table_ref = TableRef(schema_name=schema, table_name=table_name)
|
|
231
|
+
columns_contract = self._get_column_contract(inspector, conn, table_ref)
|
|
232
|
+
fks = self._get_fk_cols(inspector, table_ref)
|
|
233
|
+
|
|
234
|
+
table_contract = TableContract(
|
|
235
|
+
table=table_ref,
|
|
236
|
+
columns=columns_contract,
|
|
237
|
+
foreign_keys=fks
|
|
238
|
+
)
|
|
239
|
+
tables_contract[table_ref.full_name] = table_contract
|
|
240
|
+
|
|
241
|
+
return SchemaContract(
|
|
242
|
+
datasource_id=self.datasource_id,
|
|
243
|
+
engine_type=self.datasource_engine_type,
|
|
244
|
+
tables=tables_contract
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def fetch_schema_metadata(self) -> SchemaMetadata:
|
|
248
|
+
"""Fetches the schema metadata for the connected datasource.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
SchemaMetadata: The metadata containing tables, columns, and relationships.
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
RuntimeError: If the adapter is not connected.
|
|
255
|
+
"""
|
|
256
|
+
if not self.engine:
|
|
257
|
+
raise RuntimeError(f"Not connected to {self}. Please verify the connection details.")
|
|
258
|
+
|
|
259
|
+
exclude_schemas = {schema.lower() for schema in self.exclude_schemas}
|
|
260
|
+
inspector = inspect(self.engine)
|
|
261
|
+
tables_metadata = {}
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
schemas = [schema for schema in inspector.get_schema_names() if schema.lower() not in exclude_schemas]
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.error(f"Failed to fetch table names for {self}: {e}")
|
|
267
|
+
raise
|
|
268
|
+
|
|
269
|
+
with self.engine.connect() as conn:
|
|
270
|
+
for schema in sorted(schemas):
|
|
271
|
+
table_names = inspector.get_table_names(schema=schema)
|
|
272
|
+
for table_name in sorted(table_names):
|
|
273
|
+
table_ref = TableRef(schema_name=schema, table_name=table_name)
|
|
274
|
+
row_count = self._get_row_count(conn, table_ref)
|
|
275
|
+
columns_metadata = self._get_columns_metadata(inspector, conn, table_ref, row_count)
|
|
276
|
+
try:
|
|
277
|
+
table_comment = inspector.get_table_comment(
|
|
278
|
+
table_ref.table_name,
|
|
279
|
+
schema=table_ref.schema_name
|
|
280
|
+
).get("text")
|
|
281
|
+
except Exception:
|
|
282
|
+
table_comment = None
|
|
283
|
+
|
|
284
|
+
table_metadata = TableMetadata(
|
|
285
|
+
table=table_ref,
|
|
286
|
+
columns=columns_metadata,
|
|
287
|
+
row_count=row_count,
|
|
288
|
+
description=table_comment
|
|
289
|
+
)
|
|
290
|
+
tables_metadata[table_ref.full_name] = table_metadata
|
|
291
|
+
|
|
292
|
+
return SchemaMetadata(
|
|
293
|
+
datasource_id=self.datasource_id,
|
|
294
|
+
engine_type=self.datasource_engine_type,
|
|
295
|
+
description="",
|
|
296
|
+
domains=[],
|
|
297
|
+
tables=tables_metadata
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def fetch_schema_snapshot(self) -> SchemaSnapshot:
|
|
301
|
+
"""Fetches the schema metadata for the connected datasource.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
SchemaMetadata: The metadata containing tables, columns, and relationships.
|
|
305
|
+
|
|
306
|
+
Raises:
|
|
307
|
+
RuntimeError: If the adapter is not connected.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
schema_contract = self.fetch_schema_contact()
|
|
311
|
+
schema_metadata = self.fetch_schema_metadata()
|
|
312
|
+
|
|
313
|
+
return SchemaSnapshot(contract=schema_contract, metadata=schema_metadata)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _get_columns_metadata(self, inspector: Inspector, conn: Connection, table_ref: TableRef, row_count: int) -> Dict[str, ColumnMetadata]:
|
|
317
|
+
columns_metadata = {}
|
|
318
|
+
|
|
319
|
+
for col_info in inspector.get_columns(table_ref.table_name, schema=table_ref.schema_name):
|
|
320
|
+
columns_metadata[col_info["name"]] = ColumnMetadata(
|
|
321
|
+
description=col_info.get("comment"),
|
|
322
|
+
statistics=self._get_column_stats(conn, table_ref, col_info["name"], row_count, str(col_info["type"])),
|
|
323
|
+
synonyms=[],
|
|
324
|
+
pii=False
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
return columns_metadata
|
|
328
|
+
|
|
329
|
+
def _get_column_contract(self, inspector: Inspector, conn: Connection, table_ref: TableRef) -> Dict[str, ColumnContract]:
|
|
330
|
+
columns_contract = {}
|
|
331
|
+
pk_cols = set(inspector.get_pk_constraint(table_ref.table_name, schema=table_ref.schema_name).get("constrained_columns") or [])
|
|
332
|
+
for col_info in inspector.get_columns(table_ref.table_name, schema=table_ref.schema_name):
|
|
333
|
+
columns_contract[col_info["name"]] = ColumnContract(
|
|
334
|
+
name=col_info["name"],
|
|
335
|
+
data_type=str(col_info["type"]),
|
|
336
|
+
is_nullable=bool(col_info["nullable"]),
|
|
337
|
+
is_primary_key=col_info["name"] in pk_cols
|
|
338
|
+
)
|
|
339
|
+
return columns_contract
|
|
340
|
+
|
|
341
|
+
def _get_fk_cols(self, inspector: Inspector, table_ref: TableRef) -> List[ForeignKeyContract]:
|
|
342
|
+
fks = []
|
|
343
|
+
try:
|
|
344
|
+
for fk_info in inspector.get_foreign_keys(table_ref.table_name, schema=table_ref.schema_name):
|
|
345
|
+
fks.append(ForeignKeyContract(
|
|
346
|
+
constrained_columns=fk_info["constrained_columns"],
|
|
347
|
+
referred_table=TableRef(schema_name=fk_info.get("referred_schema"), table_name=fk_info["referred_table"]),
|
|
348
|
+
referred_columns=fk_info["referred_columns"],
|
|
349
|
+
))
|
|
350
|
+
except Exception as e:
|
|
351
|
+
logger.warning(f"Failed to fetch foreign keys for {self}: {e}")
|
|
352
|
+
return fks
|
|
353
|
+
|
|
354
|
+
def _get_row_count(self, conn: Connection, table_ref: TableRef) -> int:
|
|
355
|
+
"""Fetches the total row count for a specific table.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
table_ref (TableRef): The table reference.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
int: The total number of rows.
|
|
362
|
+
"""
|
|
363
|
+
try:
|
|
364
|
+
stmt = select(func.count()).select_from(table(table_ref.table_name, schema=table_ref.schema_name))
|
|
365
|
+
return conn.execute(stmt).scalar()
|
|
366
|
+
except Exception as e:
|
|
367
|
+
logger.warning(f"Failed to fetch row count for {self}: {table_ref.full_name}: {e}")
|
|
368
|
+
return 0
|
|
369
|
+
|
|
370
|
+
def _get_column_stats(self, conn: Connection, table_ref: TableRef, column_name: str, row_count: int, column_type: str) -> ColumnStatistics:
|
|
371
|
+
"""Fetches statistics for a specific column.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
table_ref (TableRef): The table reference.
|
|
375
|
+
column_name (str): The column name.
|
|
376
|
+
row_count (int): Total rows in the table.
|
|
377
|
+
column_type (str): The column type string.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
ColumnStatistics: Statistical data about the column.
|
|
381
|
+
"""
|
|
382
|
+
if any(x in column_type.lower() for x in ['json', 'blob', 'binary', 'bytea', 'xml', 'array']):
|
|
383
|
+
return ColumnStatistics(
|
|
384
|
+
null_percentage=0.0,
|
|
385
|
+
distinct_count=0,
|
|
386
|
+
min_value=None,
|
|
387
|
+
max_value=None,
|
|
388
|
+
sample_values=[]
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
t = table(table_ref.table_name, column(column_name), schema=table_ref.schema_name)
|
|
392
|
+
c = t.c[column_name]
|
|
393
|
+
|
|
394
|
+
stmt = (
|
|
395
|
+
select(
|
|
396
|
+
func.count(case((c == None, 1))),
|
|
397
|
+
func.min(c),
|
|
398
|
+
func.max(c),
|
|
399
|
+
func.count(func.distinct(c))
|
|
400
|
+
)
|
|
401
|
+
.select_from(t)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
result = conn.execute(stmt).fetchone()
|
|
405
|
+
null_count, min_val, max_val, distinct_count = result
|
|
406
|
+
|
|
407
|
+
return ColumnStatistics(
|
|
408
|
+
null_percentage=(null_count / row_count) if row_count > 0 else 0,
|
|
409
|
+
distinct_count=distinct_count,
|
|
410
|
+
min_value=min_val,
|
|
411
|
+
max_value=max_val,
|
|
412
|
+
sample_values=self._get_sample_values(conn, table_ref, column_name) if any(t in column_type.lower() for t in ['char', 'text', 'string', 'clob']) else []
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def _get_sample_values(self, conn: Connection, table_ref: TableRef, column_name: str, limit: int = 5) -> List[Any]:
|
|
416
|
+
"""Fetches frequently occurring sample values for a column.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
table_name (str): The table name.
|
|
420
|
+
column_name (str): The column name.
|
|
421
|
+
limit (int, optional): Max samples to return. Defaults to 5.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
List[Any]: A list of sample values.
|
|
425
|
+
"""
|
|
426
|
+
t = table(table_ref.table_name, column(column_name), schema=table_ref.schema_name)
|
|
427
|
+
c = t.c[column_name]
|
|
428
|
+
|
|
429
|
+
stmt = (
|
|
430
|
+
select(c)
|
|
431
|
+
.select_from(t)
|
|
432
|
+
.where(c != None)
|
|
433
|
+
.group_by(c)
|
|
434
|
+
.order_by(func.count().desc())
|
|
435
|
+
.limit(limit)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
return [row[0] for row in conn.execute(stmt).fetchall()]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def dry_run(self, sql: str) -> DryRunResult:
|
|
442
|
+
"""
|
|
443
|
+
Generic dry run using transaction rollback.
|
|
444
|
+
Works for SQLite, MySQL, Postgres (if not overridden), etc.
|
|
445
|
+
"""
|
|
446
|
+
try:
|
|
447
|
+
with self.engine.connect() as conn:
|
|
448
|
+
trans = conn.begin()
|
|
449
|
+
conn.execute(text(sql))
|
|
450
|
+
trans.rollback()
|
|
451
|
+
return DryRunResult(is_valid=True)
|
|
452
|
+
except Exception as e:
|
|
453
|
+
return DryRunResult(is_valid=False, error_message=str(e))
|
|
454
|
+
|
|
455
|
+
def explain(self, sql: str) -> QueryPlan:
|
|
456
|
+
raise NotImplementedError(f"Adapter {self.__class__.__name__} must implement explain")
|
|
457
|
+
|
|
458
|
+
def get_dialect(self) -> str:
|
|
459
|
+
raise NotImplementedError(f"Adapter {self.__class__.__name__} must implement get_dialect")
|
|
460
|
+
|
|
461
|
+
def cost_estimate(self, sql: str) -> CostEstimate:
|
|
462
|
+
raise NotImplementedError(f"Adapter {self.__class__.__name__} must implement cost_estimate")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def test_connection(self) -> bool:
|
|
466
|
+
"""
|
|
467
|
+
Tests the database connection by executing a simple query.
|
|
468
|
+
"""
|
|
469
|
+
try:
|
|
470
|
+
with self.engine.connect() as conn:
|
|
471
|
+
conn.execute(text("SELECT 1"))
|
|
472
|
+
return True
|
|
473
|
+
except Exception as e:
|
|
474
|
+
logger.error(f"Connection test failed for {self}: {e}")
|
|
475
|
+
return False
|
|
476
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
2
|
+
from typing import List, Optional, Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class QueryResult(BaseModel):
|
|
6
|
+
"""Normalized results from a datasource execution."""
|
|
7
|
+
columns: List[str]
|
|
8
|
+
rows: List[List[Any]]
|
|
9
|
+
row_count: int
|
|
10
|
+
raw: Optional[Any] = None
|
|
11
|
+
execution_time_ms: Optional[float] = None
|
|
12
|
+
bytes_returned: Optional[int] = None
|
|
13
|
+
|
|
14
|
+
class DryRunResult(BaseModel):
|
|
15
|
+
"""Result of a query validation/dry-run."""
|
|
16
|
+
is_valid: bool
|
|
17
|
+
error_message: Optional[str] = None
|
|
18
|
+
data: Optional[Any] = None
|
|
19
|
+
|
|
20
|
+
class QueryPlan(BaseModel):
|
|
21
|
+
"""Structure representing a database execution plan."""
|
|
22
|
+
plan_text: str
|
|
23
|
+
format: str = "text" # or 'json', 'xml'
|
|
24
|
+
|
|
25
|
+
class CostEstimate(BaseModel):
|
|
26
|
+
"""Estimated resource usage for a query."""
|
|
27
|
+
estimated_cost: float
|
|
28
|
+
estimated_rows: int
|
|
29
|
+
estimated_time_ms: Optional[float] = None
|
|
30
|
+
|
|
31
|
+
class AdapterError(BaseModel):
|
|
32
|
+
"""Standardized error envelope for adapter failures."""
|
|
33
|
+
code: str
|
|
34
|
+
message: str
|
|
35
|
+
retriable: bool
|
|
36
|
+
raw: Optional[Any] = None
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from typing import Any, List, Dict
|
|
2
|
+
from sqlalchemy import create_engine, text, inspect
|
|
3
|
+
from sqlalchemy.dialects import sqlite
|
|
4
|
+
from nl2sql.adapters.sqlalchemy_base import (
|
|
5
|
+
CostEstimate,
|
|
6
|
+
DryRunResult,
|
|
7
|
+
QueryPlan,
|
|
8
|
+
BaseSQLAlchemyAdapter
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
class SqliteConnectionConfig(BaseModel):
|
|
15
|
+
"""Strict configuration schema for SQLite adapter."""
|
|
16
|
+
type: str
|
|
17
|
+
database: str = Field(..., description="Path to SQLite database file")
|
|
18
|
+
options: Dict[str, Any] = Field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
model_config = {"extra": "ignore"}
|
|
21
|
+
|
|
22
|
+
class SqliteAdapter(BaseSQLAlchemyAdapter):
|
|
23
|
+
|
|
24
|
+
def construct_uri(self, args: Dict[str, Any]) -> str:
|
|
25
|
+
"""Constructs the SQLite connection URI.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
args: The raw connection arguments dictionary.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
str: The fully constructed SQLAlchemy connection URI.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValidationError: If the configuration is invalid.
|
|
35
|
+
"""
|
|
36
|
+
config = SqliteConnectionConfig(**args)
|
|
37
|
+
return f"sqlite:///{config.database}"
|
|
38
|
+
|
|
39
|
+
def connect(self) -> None:
|
|
40
|
+
"""Sqlite-specific connection with Locking Timeout."""
|
|
41
|
+
if not self.connection_string:
|
|
42
|
+
raise ValueError(f"Connection string is required for {self}")
|
|
43
|
+
|
|
44
|
+
connect_args = {}
|
|
45
|
+
if self.statement_timeout_ms:
|
|
46
|
+
# SQLite 'timeout' is for waiting for the lock, not execution duration.
|
|
47
|
+
# But it's the closest/best we can do for "timeout".
|
|
48
|
+
connect_args["timeout"] = self.statement_timeout_ms / 1000.0
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
self.engine = create_engine(
|
|
52
|
+
self.connection_string,
|
|
53
|
+
pool_pre_ping=True, # Less relevant for sqlite but harmless
|
|
54
|
+
execution_options=self.execution_options,
|
|
55
|
+
connect_args=connect_args
|
|
56
|
+
)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
import logging
|
|
59
|
+
logging.getLogger(__name__).error(f"Failed to connect to Sqlite: {e}")
|
|
60
|
+
raise
|
|
61
|
+
def dry_run(self, query: str) -> DryRunResult:
|
|
62
|
+
try:
|
|
63
|
+
with self.engine.connect() as conn:
|
|
64
|
+
conn.execute(text(f"EXPLAIN QUERY PLAN {query}"))
|
|
65
|
+
return DryRunResult(is_valid=True, error_message=None)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
return DryRunResult(is_valid=False, error_message=str(e))
|
|
68
|
+
|
|
69
|
+
def explain(self, query: str) -> QueryPlan:
|
|
70
|
+
return QueryPlan(original_query=query, plan="EXPLAIN QUERY PLAN not fully parsed")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cost_estimate(self, query: str) -> CostEstimate:
|
|
75
|
+
try:
|
|
76
|
+
with self.engine.connect() as conn:
|
|
77
|
+
conn.execute(text(f"EXPLAIN QUERY PLAN {query}"))
|
|
78
|
+
return CostEstimate(estimated_cost=1.0, estimated_rows=10) # Stub
|
|
79
|
+
except Exception:
|
|
80
|
+
return CostEstimate(estimated_cost=-1.0, estimated_rows=0)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_dialect(self) -> str:
|
|
84
|
+
return sqlite.dialect.name
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def exclude_schemas(self) -> set[str]:
|
|
88
|
+
return set()
|