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.
Files changed (192) hide show
  1. nl2sql/__init__.py +38 -0
  2. nl2sql/adapters/__init__.py +0 -0
  3. nl2sql/adapters/duckdb/__init__.py +0 -0
  4. nl2sql/adapters/duckdb/adapter.py +71 -0
  5. nl2sql/adapters/mssql/__init__.py +0 -0
  6. nl2sql/adapters/mssql/adapter.py +122 -0
  7. nl2sql/adapters/mysql/__init__.py +0 -0
  8. nl2sql/adapters/mysql/adapter.py +123 -0
  9. nl2sql/adapters/postgres/__init__.py +0 -0
  10. nl2sql/adapters/postgres/adapter.py +115 -0
  11. nl2sql/adapters/sqlalchemy_base/__init__.py +17 -0
  12. nl2sql/adapters/sqlalchemy_base/adapter.py +476 -0
  13. nl2sql/adapters/sqlalchemy_base/models.py +36 -0
  14. nl2sql/adapters/sqlite/__init__.py +0 -0
  15. nl2sql/adapters/sqlite/adapter.py +88 -0
  16. nl2sql/aggregation/__init__.py +3 -0
  17. nl2sql/aggregation/aggregator.py +98 -0
  18. nl2sql/aggregation/engines/__init__.py +3 -0
  19. nl2sql/aggregation/engines/polars_duckdb.py +125 -0
  20. nl2sql/api/__init__.py +0 -0
  21. nl2sql/api/auth_api.py +60 -0
  22. nl2sql/api/benchmark_api.py +114 -0
  23. nl2sql/api/datasource_api.py +132 -0
  24. nl2sql/api/indexing_api.py +59 -0
  25. nl2sql/api/llm_api.py +82 -0
  26. nl2sql/api/policy_api.py +135 -0
  27. nl2sql/api/query_api.py +138 -0
  28. nl2sql/api/result_api.py +24 -0
  29. nl2sql/api/settings_api.py +65 -0
  30. nl2sql/auth/__init__.py +8 -0
  31. nl2sql/auth/models.py +36 -0
  32. nl2sql/auth/rbac.py +25 -0
  33. nl2sql/cli/__init__.py +0 -0
  34. nl2sql/cli/checks.py +53 -0
  35. nl2sql/cli/commands/__init__.py +0 -0
  36. nl2sql/cli/commands/benchmark.py +34 -0
  37. nl2sql/cli/commands/doctor.py +49 -0
  38. nl2sql/cli/commands/indexing.py +126 -0
  39. nl2sql/cli/commands/info.py +25 -0
  40. nl2sql/cli/commands/install.py +27 -0
  41. nl2sql/cli/commands/policy.py +57 -0
  42. nl2sql/cli/commands/run.py +166 -0
  43. nl2sql/cli/commands/setup.py +415 -0
  44. nl2sql/cli/commands/visualize.py +34 -0
  45. nl2sql/cli/common/decorators.py +34 -0
  46. nl2sql/cli/config.py +24 -0
  47. nl2sql/cli/console.py +52 -0
  48. nl2sql/cli/demo/__init__.py +1 -0
  49. nl2sql/cli/demo/data.py +87 -0
  50. nl2sql/cli/demo/defaults.py +122 -0
  51. nl2sql/cli/demo/factory.py +289 -0
  52. nl2sql/cli/demo/manager.py +230 -0
  53. nl2sql/cli/demo/schemas.py +336 -0
  54. nl2sql/cli/demo/writers/__init__.py +0 -0
  55. nl2sql/cli/demo/writers/docker.py +182 -0
  56. nl2sql/cli/demo/writers/sqlite.py +88 -0
  57. nl2sql/cli/generators/datasources/__init__.py +3 -0
  58. nl2sql/cli/generators/datasources/generator.py +24 -0
  59. nl2sql/cli/generators/datasources/templates.py +7 -0
  60. nl2sql/cli/generators/env/__init__.py +3 -0
  61. nl2sql/cli/generators/env/generator.py +46 -0
  62. nl2sql/cli/generators/env/templates.py +25 -0
  63. nl2sql/cli/generators/llm/__init__.py +3 -0
  64. nl2sql/cli/generators/llm/generator.py +24 -0
  65. nl2sql/cli/generators/llm/templates.py +4 -0
  66. nl2sql/cli/generators/policies/__init__.py +3 -0
  67. nl2sql/cli/generators/policies/generator.py +20 -0
  68. nl2sql/cli/generators/policies/templates.py +2 -0
  69. nl2sql/cli/main.py +195 -0
  70. nl2sql/cli/reporting.py +878 -0
  71. nl2sql/cli/types.py +13 -0
  72. nl2sql/common/__init__.py +1 -0
  73. nl2sql/common/cancellation.py +25 -0
  74. nl2sql/common/context.py +5 -0
  75. nl2sql/common/errors.py +109 -0
  76. nl2sql/common/event_logger.py +88 -0
  77. nl2sql/common/exceptions.py +3 -0
  78. nl2sql/common/logger.py +119 -0
  79. nl2sql/common/metrics.py +50 -0
  80. nl2sql/common/resilience.py +59 -0
  81. nl2sql/common/settings.py +195 -0
  82. nl2sql/configs/__init__.py +6 -0
  83. nl2sql/configs/datasources.py +10 -0
  84. nl2sql/configs/llm.py +36 -0
  85. nl2sql/configs/manager.py +176 -0
  86. nl2sql/configs/policies.py +14 -0
  87. nl2sql/configs/sample_questions.py +11 -0
  88. nl2sql/configs/secrets.py +11 -0
  89. nl2sql/context.py +106 -0
  90. nl2sql/datasources/__init__.py +21 -0
  91. nl2sql/datasources/discovery.py +28 -0
  92. nl2sql/datasources/models.py +21 -0
  93. nl2sql/datasources/protocols.py +3 -0
  94. nl2sql/datasources/registry.py +172 -0
  95. nl2sql/evaluation/__init__.py +6 -0
  96. nl2sql/evaluation/benchmark_runner.py +320 -0
  97. nl2sql/evaluation/evaluator.py +134 -0
  98. nl2sql/evaluation/types.py +22 -0
  99. nl2sql/execution/__init__.py +4 -0
  100. nl2sql/execution/artifacts/__init__.py +3 -0
  101. nl2sql/execution/artifacts/parquet.py +41 -0
  102. nl2sql/execution/artifacts/store.py +165 -0
  103. nl2sql/execution/contracts.py +57 -0
  104. nl2sql/execution/execution_store.py +25 -0
  105. nl2sql/execution/executor/__init__.py +3 -0
  106. nl2sql/execution/executor/sql_executor.py +116 -0
  107. nl2sql/indexing/__init__.py +7 -0
  108. nl2sql/indexing/chunk_builder.py +227 -0
  109. nl2sql/indexing/embeddings.py +180 -0
  110. nl2sql/indexing/enrichment_service.py +316 -0
  111. nl2sql/indexing/models.py +209 -0
  112. nl2sql/indexing/orchestrator.py +90 -0
  113. nl2sql/indexing/vector_store.py +422 -0
  114. nl2sql/llm/__init__.py +8 -0
  115. nl2sql/llm/models.py +10 -0
  116. nl2sql/llm/registry.py +214 -0
  117. nl2sql/pipeline/__init__.py +1 -0
  118. nl2sql/pipeline/graph.py +73 -0
  119. nl2sql/pipeline/graph_utils.py +141 -0
  120. nl2sql/pipeline/nodes/__init__.py +25 -0
  121. nl2sql/pipeline/nodes/aggregator/__init__.py +4 -0
  122. nl2sql/pipeline/nodes/aggregator/node.py +55 -0
  123. nl2sql/pipeline/nodes/aggregator/prompts.py +20 -0
  124. nl2sql/pipeline/nodes/aggregator/schemas.py +28 -0
  125. nl2sql/pipeline/nodes/answer_synthesizer/__init__.py +4 -0
  126. nl2sql/pipeline/nodes/answer_synthesizer/node.py +98 -0
  127. nl2sql/pipeline/nodes/answer_synthesizer/prompts.py +19 -0
  128. nl2sql/pipeline/nodes/answer_synthesizer/schemas.py +24 -0
  129. nl2sql/pipeline/nodes/ast_planner/__init__.py +4 -0
  130. nl2sql/pipeline/nodes/ast_planner/node.py +104 -0
  131. nl2sql/pipeline/nodes/ast_planner/prompts.py +138 -0
  132. nl2sql/pipeline/nodes/ast_planner/schemas.py +236 -0
  133. nl2sql/pipeline/nodes/datasource_resolver/__init__.py +4 -0
  134. nl2sql/pipeline/nodes/datasource_resolver/node.py +253 -0
  135. nl2sql/pipeline/nodes/datasource_resolver/schemas.py +21 -0
  136. nl2sql/pipeline/nodes/decomposer/__init__.py +3 -0
  137. nl2sql/pipeline/nodes/decomposer/node.py +219 -0
  138. nl2sql/pipeline/nodes/decomposer/prompts.py +96 -0
  139. nl2sql/pipeline/nodes/decomposer/schemas.py +143 -0
  140. nl2sql/pipeline/nodes/executor/__init__.py +3 -0
  141. nl2sql/pipeline/nodes/executor/node.py +107 -0
  142. nl2sql/pipeline/nodes/generator/__init__.py +4 -0
  143. nl2sql/pipeline/nodes/generator/node.py +267 -0
  144. nl2sql/pipeline/nodes/generator/schemas.py +13 -0
  145. nl2sql/pipeline/nodes/global_planner/__init__.py +4 -0
  146. nl2sql/pipeline/nodes/global_planner/node.py +186 -0
  147. nl2sql/pipeline/nodes/global_planner/schemas.py +101 -0
  148. nl2sql/pipeline/nodes/refiner/__init__.py +4 -0
  149. nl2sql/pipeline/nodes/refiner/node.py +132 -0
  150. nl2sql/pipeline/nodes/refiner/prompts.py +28 -0
  151. nl2sql/pipeline/nodes/refiner/schemas.py +13 -0
  152. nl2sql/pipeline/nodes/schema_retriever/__init__.py +3 -0
  153. nl2sql/pipeline/nodes/schema_retriever/node.py +252 -0
  154. nl2sql/pipeline/nodes/schema_retriever/schema.py +27 -0
  155. nl2sql/pipeline/nodes/validator/__init__.py +7 -0
  156. nl2sql/pipeline/nodes/validator/node.py +839 -0
  157. nl2sql/pipeline/nodes/validator/schemas.py +12 -0
  158. nl2sql/pipeline/pipeline_runner.py +72 -0
  159. nl2sql/pipeline/routes.py +72 -0
  160. nl2sql/pipeline/runtime.py +153 -0
  161. nl2sql/pipeline/state.py +92 -0
  162. nl2sql/pipeline/subgraphs/__init__.py +5 -0
  163. nl2sql/pipeline/subgraphs/schemas.py +23 -0
  164. nl2sql/pipeline/subgraphs/sql_agent.py +167 -0
  165. nl2sql/public_api.py +199 -0
  166. nl2sql/schema/__init__.py +37 -0
  167. nl2sql/schema/in_memory_store.py +173 -0
  168. nl2sql/schema/protocol.py +88 -0
  169. nl2sql/schema/sqlite_store.py +233 -0
  170. nl2sql/schema/store.py +29 -0
  171. nl2sql/secrets/__init__.py +14 -0
  172. nl2sql/secrets/factory.py +85 -0
  173. nl2sql/secrets/interfaces.py +16 -0
  174. nl2sql/secrets/manager.py +139 -0
  175. nl2sql/secrets/models.py +56 -0
  176. nl2sql/secrets/providers/aws.py +30 -0
  177. nl2sql/secrets/providers/azure.py +49 -0
  178. nl2sql/secrets/providers/env.py +8 -0
  179. nl2sql/secrets/providers/hashi.py +46 -0
  180. nl2sql/services/__init__.py +0 -0
  181. nl2sql/services/callbacks/__init__.py +0 -0
  182. nl2sql/services/callbacks/monitor.py +84 -0
  183. nl2sql/services/callbacks/node_context.py +7 -0
  184. nl2sql/services/callbacks/node_handlers.py +187 -0
  185. nl2sql/services/callbacks/node_metrics.py +14 -0
  186. nl2sql/services/callbacks/presenter.py +12 -0
  187. nl2sql/services/callbacks/token_handler.py +56 -0
  188. nl2sql_engine-0.1.0.dist-info/METADATA +295 -0
  189. nl2sql_engine-0.1.0.dist-info/RECORD +192 -0
  190. nl2sql_engine-0.1.0.dist-info/WHEEL +5 -0
  191. nl2sql_engine-0.1.0.dist-info/entry_points.txt +9 -0
  192. 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()
@@ -0,0 +1,3 @@
1
+ from .aggregator import AggregationService
2
+
3
+ __all__ = ["AggregationService"]