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
nl2sql/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# nl2sql package
|
|
2
|
+
|
|
3
|
+
from .public_api import NL2SQL, QueryResult
|
|
4
|
+
|
|
5
|
+
# Also expose individual API modules for more granular access
|
|
6
|
+
from .api.query_api import QueryAPI
|
|
7
|
+
from .api.datasource_api import DatasourceAPI
|
|
8
|
+
from .api.llm_api import LLM_API
|
|
9
|
+
from .api.indexing_api import IndexingAPI
|
|
10
|
+
from .api.auth_api import AuthAPI
|
|
11
|
+
from .api.settings_api import SettingsAPI
|
|
12
|
+
from .api.result_api import ResultAPI
|
|
13
|
+
from .api.policy_api import PolicyAPI
|
|
14
|
+
from .api.benchmark_api import BenchmarkAPI
|
|
15
|
+
|
|
16
|
+
# Also expose core models and enums
|
|
17
|
+
from .common.errors import ErrorSeverity, ErrorCode, PipelineError
|
|
18
|
+
from .auth.models import UserContext
|
|
19
|
+
from .evaluation.types import BenchmarkConfig
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"NL2SQL",
|
|
23
|
+
"QueryResult",
|
|
24
|
+
"QueryAPI",
|
|
25
|
+
"DatasourceAPI",
|
|
26
|
+
"LLM_API",
|
|
27
|
+
"IndexingAPI",
|
|
28
|
+
"AuthAPI",
|
|
29
|
+
"SettingsAPI",
|
|
30
|
+
"ResultAPI",
|
|
31
|
+
"PolicyAPI",
|
|
32
|
+
"BenchmarkAPI",
|
|
33
|
+
"ErrorSeverity",
|
|
34
|
+
"ErrorCode",
|
|
35
|
+
"PipelineError",
|
|
36
|
+
"UserContext",
|
|
37
|
+
"BenchmarkConfig",
|
|
38
|
+
]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
|
|
3
|
+
from nl2sql.adapters.sqlalchemy_base import (
|
|
4
|
+
CostEstimate,
|
|
5
|
+
DryRunResult,
|
|
6
|
+
QueryPlan,
|
|
7
|
+
BaseSQLAlchemyAdapter
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DuckdbConnectionConfig(BaseModel):
|
|
14
|
+
"""Strict configuration schema for DuckDB adapter."""
|
|
15
|
+
type: str
|
|
16
|
+
database: str = Field(..., description="Path to DuckDB database file, or ':memory:'")
|
|
17
|
+
options: Dict[str, Any] = Field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
model_config = {"extra": "ignore"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DuckdbAdapter(BaseSQLAlchemyAdapter):
|
|
23
|
+
|
|
24
|
+
def construct_uri(self, args: Dict[str, Any]) -> str:
|
|
25
|
+
"""Constructs the DuckDB 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 = DuckdbConnectionConfig(**args)
|
|
37
|
+
return f"duckdb:///{config.database}"
|
|
38
|
+
|
|
39
|
+
def dry_run(self, query: str) -> DryRunResult:
|
|
40
|
+
try:
|
|
41
|
+
self.execute_sql(f"EXPLAIN {query}")
|
|
42
|
+
return DryRunResult(is_valid=True, error_message=None)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
return DryRunResult(is_valid=False, error_message=str(e))
|
|
45
|
+
|
|
46
|
+
def explain(self, query: str) -> QueryPlan:
|
|
47
|
+
try:
|
|
48
|
+
res = self.execute_sql(f"EXPLAIN {query}")
|
|
49
|
+
# EXPLAIN yields (explain_key, explain_value) rows; the plan is the value.
|
|
50
|
+
plan_text = "\n".join(str(row[-1]) for row in res.rows)
|
|
51
|
+
return QueryPlan(plan_text=plan_text)
|
|
52
|
+
except Exception:
|
|
53
|
+
return QueryPlan(plan_text="Could not retrieve plan")
|
|
54
|
+
|
|
55
|
+
def cost_estimate(self, query: str) -> CostEstimate:
|
|
56
|
+
# DuckDB's EXPLAIN prints an operator tree with no cost or cardinality
|
|
57
|
+
# figures, so there is nothing real to report here.
|
|
58
|
+
try:
|
|
59
|
+
self.execute_sql(f"EXPLAIN {query}")
|
|
60
|
+
return CostEstimate(estimated_cost=1.0, estimated_rows=10) # Stub
|
|
61
|
+
except Exception:
|
|
62
|
+
return CostEstimate(estimated_cost=-1.0, estimated_rows=0)
|
|
63
|
+
|
|
64
|
+
def get_dialect(self) -> str:
|
|
65
|
+
return "duckdb"
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def exclude_schemas(self) -> set[str]:
|
|
69
|
+
# DuckDB qualifies schema names with their catalog; only the attached
|
|
70
|
+
# database's own schemas carry user tables.
|
|
71
|
+
return {"system.main", "system.information_schema", "temp.main"}
|
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from typing import Any, List, Dict
|
|
2
|
+
from sqlalchemy import create_engine, text, inspect
|
|
3
|
+
from sqlalchemy.dialects import mssql
|
|
4
|
+
from nl2sql.adapters.sqlalchemy_base import (
|
|
5
|
+
CostEstimate,
|
|
6
|
+
DryRunResult,
|
|
7
|
+
QueryPlan,
|
|
8
|
+
)
|
|
9
|
+
from nl2sql.adapters.sqlalchemy_base import BaseSQLAlchemyAdapter
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, SecretStr
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
class MssqlConnectionConfig(BaseModel):
|
|
15
|
+
"""Strict configuration schema for MSSQL adapter."""
|
|
16
|
+
type: str = Field("mssql", description="Connection type")
|
|
17
|
+
host: str = Field(..., description="Server hostname")
|
|
18
|
+
user: Optional[str] = None
|
|
19
|
+
password: Optional[SecretStr] = None
|
|
20
|
+
port: int = 1433
|
|
21
|
+
database: str = Field(..., description="Database name")
|
|
22
|
+
driver: str = "ODBC Driver 17 for SQL Server"
|
|
23
|
+
trusted_connection: bool = False
|
|
24
|
+
options: Dict[str, Any] = Field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
model_config = {"extra": "ignore"}
|
|
27
|
+
|
|
28
|
+
class MssqlAdapter(BaseSQLAlchemyAdapter):
|
|
29
|
+
|
|
30
|
+
def construct_uri(self, args: Dict[str, Any]) -> str:
|
|
31
|
+
"""Constructs the MSSQL connection URI.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
args: The raw connection arguments dictionary.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
str: The fully constructed SQLAlchemy connection URI.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValidationError: If the configuration is invalid.
|
|
41
|
+
"""
|
|
42
|
+
config = MssqlConnectionConfig(**args)
|
|
43
|
+
|
|
44
|
+
user = config.user or ""
|
|
45
|
+
password = config.password.get_secret_value() if config.password else ""
|
|
46
|
+
host = config.host
|
|
47
|
+
port = config.port
|
|
48
|
+
database = config.database
|
|
49
|
+
driver = config.driver
|
|
50
|
+
|
|
51
|
+
options = config.options.copy()
|
|
52
|
+
options["driver"] = driver
|
|
53
|
+
|
|
54
|
+
if config.trusted_connection:
|
|
55
|
+
options["Trusted_Connection"] = "yes"
|
|
56
|
+
|
|
57
|
+
creds = f"{user}:{password}@" if user or password else ""
|
|
58
|
+
netloc = f"{host}:{port}"
|
|
59
|
+
|
|
60
|
+
from urllib.parse import urlencode
|
|
61
|
+
query_str = "?" + urlencode(options)
|
|
62
|
+
|
|
63
|
+
return f"mssql+pyodbc://{creds}{netloc}/{database}{query_str}"
|
|
64
|
+
|
|
65
|
+
def dry_run(self, sql: str) -> DryRunResult:
|
|
66
|
+
try:
|
|
67
|
+
with self.engine.connect() as conn:
|
|
68
|
+
conn.execute(text("SET NOEXEC ON"))
|
|
69
|
+
try:
|
|
70
|
+
conn.execute(text(sql))
|
|
71
|
+
valid = True
|
|
72
|
+
msg = None
|
|
73
|
+
except Exception as e:
|
|
74
|
+
valid = False
|
|
75
|
+
msg = str(e)
|
|
76
|
+
finally:
|
|
77
|
+
conn.execute(text("SET NOEXEC OFF"))
|
|
78
|
+
return DryRunResult(is_valid=valid, error_message=msg)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
return DryRunResult(is_valid=False, error_message=str(e))
|
|
81
|
+
|
|
82
|
+
def explain(self, sql: str) -> QueryPlan:
|
|
83
|
+
try:
|
|
84
|
+
with self.engine.connect() as conn:
|
|
85
|
+
conn.execute(text("SET SHOWPLAN_XML ON"))
|
|
86
|
+
except Exception as e:
|
|
87
|
+
return QueryPlan(plan_text=f"Error: {e}")
|
|
88
|
+
|
|
89
|
+
def get_dialect(self) -> str:
|
|
90
|
+
"""MSSQL uses T-SQL dialect."""
|
|
91
|
+
return mssql.dialect.name
|
|
92
|
+
|
|
93
|
+
def cost_estimate(self, sql: str) -> CostEstimate:
|
|
94
|
+
import re
|
|
95
|
+
try:
|
|
96
|
+
with self.engine.connect() as conn:
|
|
97
|
+
conn.execute(text("SET SHOWPLAN_XML ON"))
|
|
98
|
+
try:
|
|
99
|
+
res = conn.execute(text(sql)).fetchone()
|
|
100
|
+
finally:
|
|
101
|
+
conn.execute(text("SET SHOWPLAN_XML OFF"))
|
|
102
|
+
|
|
103
|
+
if res and res[0]:
|
|
104
|
+
xml_str = res[0]
|
|
105
|
+
# Extract cost and rows using regex to avoid namespace complexity
|
|
106
|
+
# StatementSubTreeCost="0.00328" StatementEstRows="1"
|
|
107
|
+
cost_match = re.search(r'StatementSubTreeCost="([^"]+)"', xml_str)
|
|
108
|
+
rows_match = re.search(r'StatementEstRows="([^"]+)"', xml_str)
|
|
109
|
+
|
|
110
|
+
return CostEstimate(
|
|
111
|
+
estimated_cost=float(cost_match.group(1)) if cost_match else 0.0,
|
|
112
|
+
estimated_rows=float(rows_match.group(1)) if rows_match else 0
|
|
113
|
+
)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
return CostEstimate(estimated_cost=0.0, estimated_rows=0)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def exclude_schemas(self) -> set[str]:
|
|
121
|
+
return {"sys", "INFORMATION_SCHEMA"}
|
|
122
|
+
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from typing import Any, List, Dict
|
|
2
|
+
from sqlalchemy import create_engine, text, inspect
|
|
3
|
+
from sqlalchemy.dialects import mysql
|
|
4
|
+
from nl2sql.adapters.sqlalchemy_base import (
|
|
5
|
+
CostEstimate,
|
|
6
|
+
DryRunResult,
|
|
7
|
+
QueryPlan,
|
|
8
|
+
BaseSQLAlchemyAdapter
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, SecretStr
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
class MysqlConnectionConfig(BaseModel):
|
|
15
|
+
"""Strict configuration schema for MySQL adapter."""
|
|
16
|
+
type: str
|
|
17
|
+
host: str = Field(..., description="MySQL server hostname")
|
|
18
|
+
user: str = Field(..., description="Username")
|
|
19
|
+
password: SecretStr = Field(..., description="Password")
|
|
20
|
+
port: int = 3306
|
|
21
|
+
database: str = Field(..., description="Database name")
|
|
22
|
+
options: Dict[str, Any] = Field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
model_config = {"extra": "ignore"}
|
|
25
|
+
|
|
26
|
+
class MysqlAdapter(BaseSQLAlchemyAdapter):
|
|
27
|
+
|
|
28
|
+
def construct_uri(self, args: Dict[str, Any]) -> str:
|
|
29
|
+
"""Constructs the MySQL connection URI.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
args: The raw connection arguments dictionary.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
str: The fully constructed SQLAlchemy connection URI.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValidationError: If the configuration is invalid.
|
|
39
|
+
"""
|
|
40
|
+
config = MysqlConnectionConfig(**args)
|
|
41
|
+
|
|
42
|
+
user = config.user
|
|
43
|
+
password = config.password.get_secret_value()
|
|
44
|
+
host = config.host
|
|
45
|
+
port = config.port
|
|
46
|
+
database = config.database
|
|
47
|
+
options = config.options.copy()
|
|
48
|
+
|
|
49
|
+
creds = f"{user}:{password}@" if user or password else ""
|
|
50
|
+
netloc = f"{host}:{port}"
|
|
51
|
+
|
|
52
|
+
query_str = ""
|
|
53
|
+
if options:
|
|
54
|
+
from urllib.parse import urlencode
|
|
55
|
+
query_str = "?" + urlencode(options)
|
|
56
|
+
|
|
57
|
+
return f"mysql+pymysql://{creds}{netloc}/{database}{query_str}"
|
|
58
|
+
|
|
59
|
+
def connect(self) -> None:
|
|
60
|
+
"""MySQL-specific connection with Native Server-Side Timeout."""
|
|
61
|
+
if not self.connection_string:
|
|
62
|
+
raise ValueError(f"Connection string is required for {self}")
|
|
63
|
+
|
|
64
|
+
connect_args = {}
|
|
65
|
+
if self.statement_timeout_ms:
|
|
66
|
+
# Native MySQL Timeout (server-side)
|
|
67
|
+
# SET MAX_EXECUTION_TIME={ms}
|
|
68
|
+
connect_args["init_command"] = f"SET MAX_EXECUTION_TIME={self.statement_timeout_ms}"
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
self.engine = create_engine(
|
|
72
|
+
self.connection_string,
|
|
73
|
+
pool_pre_ping=True,
|
|
74
|
+
execution_options=self.execution_options,
|
|
75
|
+
connect_args=connect_args
|
|
76
|
+
)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
import logging
|
|
79
|
+
logging.getLogger(__name__).error(f"Failed to connect to MySQL: {e}")
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
def dry_run(self, sql: str) -> DryRunResult:
|
|
83
|
+
try:
|
|
84
|
+
with self.engine.connect() as conn:
|
|
85
|
+
trans = conn.begin()
|
|
86
|
+
conn.execute(text(sql))
|
|
87
|
+
trans.rollback()
|
|
88
|
+
return DryRunResult(is_valid=True)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
return DryRunResult(is_valid=False, error_message=str(e))
|
|
91
|
+
|
|
92
|
+
def explain(self, sql: str) -> QueryPlan:
|
|
93
|
+
try:
|
|
94
|
+
with self.engine.connect() as conn:
|
|
95
|
+
res = conn.execute(text(f"EXPLAIN FORMAT=JSON {sql}")).scalar()
|
|
96
|
+
return QueryPlan(plan_text=str(res))
|
|
97
|
+
except Exception as e:
|
|
98
|
+
return QueryPlan(plan_text=f"Error: {e}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cost_estimate(self, sql: str) -> CostEstimate:
|
|
102
|
+
import json
|
|
103
|
+
try:
|
|
104
|
+
with self.engine.connect() as conn:
|
|
105
|
+
res = conn.execute(text(f"EXPLAIN FORMAT=JSON {sql}")).scalar()
|
|
106
|
+
if res:
|
|
107
|
+
data = json.loads(res)
|
|
108
|
+
cost_info = data.get('query_block', {}).get('cost_info', {})
|
|
109
|
+
return CostEstimate(
|
|
110
|
+
estimated_cost=float(cost_info.get('query_cost', 0.0)),
|
|
111
|
+
estimated_rows=0 # MySQL doesn't give a single total rows estimate easily
|
|
112
|
+
)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return CostEstimate(estimated_cost=0.0, estimated_rows=0)
|
|
116
|
+
|
|
117
|
+
def get_dialect(self) -> str:
|
|
118
|
+
return mysql.dialect.name
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def exclude_schemas(self) -> set[str]:
|
|
123
|
+
return {"mysql", "INFORMATION_SCHEMA", "performance_schema", "sys"}
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
from sqlalchemy import create_engine, inspect, text
|
|
3
|
+
from sqlalchemy.dialects import postgresql
|
|
4
|
+
from nl2sql.adapters.sqlalchemy_base import (
|
|
5
|
+
DryRunResult,
|
|
6
|
+
QueryPlan,
|
|
7
|
+
CostEstimate,
|
|
8
|
+
BaseSQLAlchemyAdapter
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, SecretStr
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
class PostgresConnectionConfig(BaseModel):
|
|
15
|
+
"""Strict configuration schema for Postgres adapter."""
|
|
16
|
+
type: str = Field("postgresql", description="Connection type")
|
|
17
|
+
host: str = Field(..., description="Postgres server hostname")
|
|
18
|
+
user: str = Field(..., description="Username")
|
|
19
|
+
password: SecretStr = Field(..., description="Password")
|
|
20
|
+
port: int = 5432
|
|
21
|
+
database: str = Field(..., description="Database name")
|
|
22
|
+
options: Dict[str, Any] = Field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
model_config = {"extra": "ignore"}
|
|
25
|
+
|
|
26
|
+
class PostgresAdapter(BaseSQLAlchemyAdapter):
|
|
27
|
+
|
|
28
|
+
def construct_uri(self, args: Dict[str, Any]) -> str:
|
|
29
|
+
"""Constructs the Postgres connection URI.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
args: The raw connection arguments dictionary.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
str: The fully constructed SQLAlchemy connection URI.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValidationError: If the configuration is invalid.
|
|
39
|
+
"""
|
|
40
|
+
config = PostgresConnectionConfig(**args)
|
|
41
|
+
|
|
42
|
+
user = config.user
|
|
43
|
+
password = config.password.get_secret_value()
|
|
44
|
+
host = config.host
|
|
45
|
+
port = config.port
|
|
46
|
+
database = config.database
|
|
47
|
+
options = config.options.copy()
|
|
48
|
+
|
|
49
|
+
creds = f"{user}:{password}@" if user or password else ""
|
|
50
|
+
netloc = f"{host}:{port}"
|
|
51
|
+
|
|
52
|
+
query_str = ""
|
|
53
|
+
if options:
|
|
54
|
+
from urllib.parse import urlencode
|
|
55
|
+
query_str = "?" + urlencode(options)
|
|
56
|
+
|
|
57
|
+
return f"postgresql://{creds}{netloc}/{database}{query_str}"
|
|
58
|
+
|
|
59
|
+
def connect(self) -> None:
|
|
60
|
+
"""Postgres-specific connection with Native Server-Side Timeout."""
|
|
61
|
+
if not self.connection_string:
|
|
62
|
+
raise ValueError(f"Connection string is required for {self}")
|
|
63
|
+
|
|
64
|
+
connect_args = {}
|
|
65
|
+
if self.statement_timeout_ms:
|
|
66
|
+
connect_args["options"] = f"-c statement_timeout={self.statement_timeout_ms}"
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
self.engine = create_engine(
|
|
70
|
+
self.connection_string,
|
|
71
|
+
pool_pre_ping=True,
|
|
72
|
+
execution_options=self.execution_options, # Pass standard options too
|
|
73
|
+
connect_args=connect_args
|
|
74
|
+
)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
import logging
|
|
77
|
+
logging.getLogger(__name__).error(f"Failed to connect to Postgres: {e}")
|
|
78
|
+
raise
|
|
79
|
+
|
|
80
|
+
def dry_run(self, sql: str) -> DryRunResult:
|
|
81
|
+
try:
|
|
82
|
+
self.execute_sql(f"EXPLAIN {sql}")
|
|
83
|
+
return DryRunResult(is_valid=True)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return DryRunResult(is_valid=False, error_message=str(e))
|
|
86
|
+
|
|
87
|
+
def explain(self, sql: str) -> QueryPlan:
|
|
88
|
+
try:
|
|
89
|
+
res = self.execute_sql(f"EXPLAIN (FORMAT JSON) {sql}")
|
|
90
|
+
return QueryPlan(plan_text=str(res.rows))
|
|
91
|
+
except Exception: # Fallback
|
|
92
|
+
return QueryPlan(plan_text="Could not retrieve plan")
|
|
93
|
+
|
|
94
|
+
def cost_estimate(self, sql: str) -> CostEstimate:
|
|
95
|
+
try:
|
|
96
|
+
res = self.execute_sql(f"EXPLAIN (FORMAT JSON) {sql}")
|
|
97
|
+
if res.rows and res.rows[0]:
|
|
98
|
+
plan_data = res.rows[0][0] # The JSON object/list
|
|
99
|
+
if isinstance(plan_data, list) and len(plan_data) > 0:
|
|
100
|
+
root = plan_data[0].get('Plan', {})
|
|
101
|
+
return CostEstimate(
|
|
102
|
+
estimated_cost=float(root.get('Total Cost', 0.0)),
|
|
103
|
+
estimated_rows=int(root.get('Plan Rows', 0))
|
|
104
|
+
)
|
|
105
|
+
return CostEstimate(estimated_cost=0.0, estimated_rows=0)
|
|
106
|
+
except Exception:
|
|
107
|
+
return CostEstimate(estimated_cost=0.0, estimated_rows=0)
|
|
108
|
+
|
|
109
|
+
def get_dialect(self) -> str:
|
|
110
|
+
return postgresql.dialect.name
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def exclude_schemas(self) -> set[str]:
|
|
115
|
+
return {"pg_catalog", "information_schema"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .adapter import BaseSQLAlchemyAdapter
|
|
2
|
+
from .models import (
|
|
3
|
+
QueryResult,
|
|
4
|
+
CostEstimate,
|
|
5
|
+
DryRunResult,
|
|
6
|
+
QueryPlan,
|
|
7
|
+
AdapterError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BaseSQLAlchemyAdapter",
|
|
12
|
+
"QueryResult",
|
|
13
|
+
"CostEstimate",
|
|
14
|
+
"DryRunResult",
|
|
15
|
+
"QueryPlan",
|
|
16
|
+
"AdapterError",
|
|
17
|
+
]
|