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,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlglot
|
|
4
|
+
from sqlglot import expressions as exp
|
|
5
|
+
from typing import Dict, Any, List, Union, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from nl2sql.pipeline.state import SubgraphExecutionState
|
|
9
|
+
from nl2sql.common.errors import PipelineError, ErrorSeverity, ErrorCode
|
|
10
|
+
from nl2sql.datasources import DatasourceRegistry
|
|
11
|
+
from nl2sql.common.logger import get_logger
|
|
12
|
+
from nl2sql.pipeline.nodes.ast_planner.schemas import PlanModel, Expr
|
|
13
|
+
from nl2sql.pipeline.nodes.generator.schemas import GeneratorResponse
|
|
14
|
+
from nl2sql.context import NL2SQLContext
|
|
15
|
+
|
|
16
|
+
logger = get_logger("generator")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SqlVisitor:
|
|
20
|
+
"""Visits the PlanModel AST and converts it to sqlglot expressions.
|
|
21
|
+
|
|
22
|
+
This visitor traverses the deterministic AST (Expr) produced by the Planner
|
|
23
|
+
and builds a corresponding sqlglot expression tree, which can then be
|
|
24
|
+
transpiled to the target dialect.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def visit(self, expr: Expr) -> exp.Expression:
|
|
28
|
+
"""Dispatches the visit to the appropriate method based on expression kind.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
expr (Expr): The expression node to visit.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
exp.Expression: The corresponding sqlglot expression.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ValueError: If the expression kind is unknown.
|
|
38
|
+
"""
|
|
39
|
+
if expr.kind == "literal":
|
|
40
|
+
return self._visit_literal(expr)
|
|
41
|
+
elif expr.kind == "column":
|
|
42
|
+
return self._visit_column(expr)
|
|
43
|
+
elif expr.kind == "func":
|
|
44
|
+
return self._visit_func(expr)
|
|
45
|
+
elif expr.kind == "binary":
|
|
46
|
+
return self._visit_binary(expr)
|
|
47
|
+
elif expr.kind == "unary":
|
|
48
|
+
return self._visit_unary(expr)
|
|
49
|
+
elif expr.kind == "case":
|
|
50
|
+
return self._visit_case(expr)
|
|
51
|
+
raise ValueError(f"Unknown expression kind: {expr.kind}")
|
|
52
|
+
|
|
53
|
+
def _visit_literal(self, expr: Expr) -> exp.Expression:
|
|
54
|
+
"""Converts a literal expression to sqlglot."""
|
|
55
|
+
val = expr.value
|
|
56
|
+
if val is None:
|
|
57
|
+
return exp.Null()
|
|
58
|
+
if isinstance(val, bool):
|
|
59
|
+
return exp.Boolean(this="TRUE" if val else "FALSE")
|
|
60
|
+
if isinstance(val, (int, float)):
|
|
61
|
+
return exp.Literal.number(str(val))
|
|
62
|
+
return exp.Literal.string(str(val))
|
|
63
|
+
|
|
64
|
+
def _visit_column(self, expr: Expr) -> exp.Column:
|
|
65
|
+
"""Converts a column expression to sqlglot."""
|
|
66
|
+
ident = exp.Identifier(this=expr.column_name, quoted=False)
|
|
67
|
+
if expr.alias:
|
|
68
|
+
return exp.Column(this=ident, table=exp.Identifier(this=expr.alias, quoted=False))
|
|
69
|
+
return exp.Column(this=ident)
|
|
70
|
+
|
|
71
|
+
def _visit_func(self, expr: Expr) -> exp.Expression:
|
|
72
|
+
"""Converts a function call expression to sqlglot."""
|
|
73
|
+
if str(expr.func_name).upper() in ("TUPLE", "LIST"):
|
|
74
|
+
return exp.Tuple(expressions=[self.visit(arg) for arg in expr.args])
|
|
75
|
+
|
|
76
|
+
return exp.Anonymous(
|
|
77
|
+
this=expr.func_name,
|
|
78
|
+
expressions=[self.visit(arg) for arg in expr.args]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _visit_binary(self, expr: Expr) -> exp.Expression:
|
|
82
|
+
"""Converts a binary operation expression to sqlglot."""
|
|
83
|
+
if not expr.left or not expr.right:
|
|
84
|
+
raise ValueError("Binary expression missing operands")
|
|
85
|
+
|
|
86
|
+
left = self.visit(expr.left)
|
|
87
|
+
right = self.visit(expr.right)
|
|
88
|
+
op = str(expr.op).upper()
|
|
89
|
+
|
|
90
|
+
if op == "=": return exp.EQ(this=left, expression=right)
|
|
91
|
+
if op == "!=": return exp.NEQ(this=left, expression=right)
|
|
92
|
+
if op == ">": return exp.GT(this=left, expression=right)
|
|
93
|
+
if op == "<": return exp.LT(this=left, expression=right)
|
|
94
|
+
if op == ">=": return exp.GTE(this=left, expression=right)
|
|
95
|
+
if op == "<=": return exp.LTE(this=left, expression=right)
|
|
96
|
+
if op == "AND":
|
|
97
|
+
if isinstance(left, exp.Or): left = exp.Paren(this=left)
|
|
98
|
+
if isinstance(right, exp.Or): right = exp.Paren(this=right)
|
|
99
|
+
return exp.And(this=left, expression=right)
|
|
100
|
+
if op == "OR": return exp.Or(this=left, expression=right)
|
|
101
|
+
if op == "LIKE": return exp.Like(this=left, expression=right)
|
|
102
|
+
if op == "IN":
|
|
103
|
+
values = right.expressions if isinstance(right, exp.Tuple) else [right]
|
|
104
|
+
return exp.In(this=left, expressions=values)
|
|
105
|
+
|
|
106
|
+
return exp.Anonymous(this=op, expressions=[left, right])
|
|
107
|
+
|
|
108
|
+
def _visit_unary(self, expr: Expr) -> exp.Expression:
|
|
109
|
+
"""Converts a unary operation expression to sqlglot."""
|
|
110
|
+
target = expr.expr
|
|
111
|
+
if not target:
|
|
112
|
+
raise ValueError("Unary expression missing target")
|
|
113
|
+
|
|
114
|
+
node = self.visit(target)
|
|
115
|
+
op = str(expr.op).upper()
|
|
116
|
+
|
|
117
|
+
if op == "NOT":
|
|
118
|
+
return exp.Not(this=node)
|
|
119
|
+
if op == "-":
|
|
120
|
+
return exp.Neg(this=node)
|
|
121
|
+
|
|
122
|
+
return exp.Paren(this=node)
|
|
123
|
+
|
|
124
|
+
def _visit_case(self, expr: Expr) -> exp.Case:
|
|
125
|
+
"""Converts a CASE expression to sqlglot."""
|
|
126
|
+
when_list = []
|
|
127
|
+
|
|
128
|
+
if expr.whens:
|
|
129
|
+
for w in expr.whens:
|
|
130
|
+
when_list.append(
|
|
131
|
+
exp.When(
|
|
132
|
+
this=self.visit(w.condition),
|
|
133
|
+
then=self.visit(w.result)
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
default = self.visit(expr.else_expr) if expr.else_expr else None
|
|
138
|
+
return exp.Case(ifs=when_list, default=default)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class GeneratorNode:
|
|
142
|
+
"""Generates the final SQL string from the PlanModel using sqlglot.
|
|
143
|
+
|
|
144
|
+
Attributes:
|
|
145
|
+
registry (DatasourceRegistry): The registry to fetch datasource dialects.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self, ctx: NL2SQLContext):
|
|
149
|
+
"""Initializes the GeneratorNode.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
registry (DatasourceRegistry): The registry of datasources.
|
|
153
|
+
"""
|
|
154
|
+
self.node_name = self.__class__.__name__.lower().replace('node', '')
|
|
155
|
+
self.ds_registry = ctx.ds_registry
|
|
156
|
+
|
|
157
|
+
def __call__(self, state: SubgraphExecutionState) -> Dict[str, Any]:
|
|
158
|
+
"""Executes the generator node.
|
|
159
|
+
|
|
160
|
+
Converts the PlanModel into a SQL string tailored for the target
|
|
161
|
+
datasource's dialect.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
state (SubgraphExecutionState): The current state containing the execution plan.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Dict[str, Any]: A dictionary with the generated 'sql_draft' and reasoning.
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
datasource_id = state.sub_query.datasource_id if state.sub_query else None
|
|
171
|
+
if not datasource_id:
|
|
172
|
+
raise ValueError("No datasource selected")
|
|
173
|
+
plan = state.ast_planner_response.plan if state.ast_planner_response else None
|
|
174
|
+
if not plan:
|
|
175
|
+
raise ValueError("No plan provided")
|
|
176
|
+
|
|
177
|
+
adapter = self.ds_registry.get_adapter(datasource_id)
|
|
178
|
+
dialect = adapter.get_dialect()
|
|
179
|
+
|
|
180
|
+
row_limit = adapter.row_limit or 1000
|
|
181
|
+
limit = min(int(plan.limit or row_limit), row_limit)
|
|
182
|
+
|
|
183
|
+
sql = self._generate_sql(plan, limit, dialect)
|
|
184
|
+
|
|
185
|
+
response = GeneratorResponse(
|
|
186
|
+
sql_draft=sql,
|
|
187
|
+
reasoning=[{"node": self.node_name, "content": ["Generated SQL", sql]}],
|
|
188
|
+
)
|
|
189
|
+
return {
|
|
190
|
+
"generator_response": response,
|
|
191
|
+
"reasoning": response.reasoning,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
logger.exception(exc)
|
|
196
|
+
error = PipelineError(
|
|
197
|
+
node=self.node_name,
|
|
198
|
+
message=str(exc),
|
|
199
|
+
error_code=ErrorCode.SQL_GEN_FAILED,
|
|
200
|
+
severity=ErrorSeverity.ERROR,
|
|
201
|
+
stack_trace=str(exc),
|
|
202
|
+
)
|
|
203
|
+
return {
|
|
204
|
+
"generator_response": GeneratorResponse(errors=[error]),
|
|
205
|
+
"errors": [error],
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def _generate_sql(self, plan: PlanModel, limit: int, dialect: str) -> str:
|
|
209
|
+
"""Internal helper to build and optimize the SQL query."""
|
|
210
|
+
visitor = SqlVisitor()
|
|
211
|
+
query = exp.select()
|
|
212
|
+
|
|
213
|
+
for s in sorted(plan.select_items, key=lambda x: x.ordinal):
|
|
214
|
+
e = visitor.visit(s.expr)
|
|
215
|
+
if s.alias:
|
|
216
|
+
e = exp.Alias(this=e, alias=exp.Identifier(this=s.alias, quoted=False))
|
|
217
|
+
query = query.select(e)
|
|
218
|
+
|
|
219
|
+
tables = sorted(plan.tables, key=lambda x: x.ordinal)
|
|
220
|
+
if not tables:
|
|
221
|
+
raise ValueError("Plan has no tables")
|
|
222
|
+
|
|
223
|
+
primary = tables[0]
|
|
224
|
+
tbl = exp.Table(this=exp.Identifier(this=primary.name, quoted=False))
|
|
225
|
+
|
|
226
|
+
if primary.schema_name:
|
|
227
|
+
tbl.set("db", exp.Identifier(this=primary.schema_name, quoted=False))
|
|
228
|
+
if primary.database:
|
|
229
|
+
tbl.set("catalog", exp.Identifier(this=primary.database, quoted=False))
|
|
230
|
+
if primary.alias:
|
|
231
|
+
tbl.set("alias", exp.TableAlias(this=exp.Identifier(this=primary.alias, quoted=False)))
|
|
232
|
+
|
|
233
|
+
query = query.from_(tbl)
|
|
234
|
+
|
|
235
|
+
alias_map = {t.alias: t.name for t in tables}
|
|
236
|
+
|
|
237
|
+
for j in sorted(plan.joins, key=lambda x: x.ordinal):
|
|
238
|
+
name = alias_map.get(j.right_alias)
|
|
239
|
+
if not name:
|
|
240
|
+
raise ValueError(f"Join references unknown alias {j.right_alias}")
|
|
241
|
+
|
|
242
|
+
right = exp.Table(this=exp.Identifier(this=name, quoted=False))
|
|
243
|
+
right.set("alias", exp.TableAlias(this=exp.Identifier(this=j.right_alias, quoted=False)))
|
|
244
|
+
|
|
245
|
+
condition = visitor.visit(j.condition)
|
|
246
|
+
|
|
247
|
+
query = query.join(
|
|
248
|
+
right,
|
|
249
|
+
on=condition,
|
|
250
|
+
join_type=j.join_type
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if plan.where:
|
|
254
|
+
query = query.where(visitor.visit(plan.where))
|
|
255
|
+
|
|
256
|
+
for g in sorted(plan.group_by, key=lambda x: x.ordinal):
|
|
257
|
+
query = query.group_by(visitor.visit(g.expr))
|
|
258
|
+
|
|
259
|
+
if plan.having:
|
|
260
|
+
query = query.having(visitor.visit(plan.having))
|
|
261
|
+
|
|
262
|
+
for o in sorted(plan.order_by, key=lambda x: x.ordinal):
|
|
263
|
+
query = query.order_by(visitor.visit(o.expr), desc=(o.direction == "desc"))
|
|
264
|
+
|
|
265
|
+
query = query.limit(limit)
|
|
266
|
+
|
|
267
|
+
return query.sql(dialect=dialect)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from nl2sql.common.errors import PipelineError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GeneratorResponse(BaseModel):
|
|
11
|
+
sql_draft: Optional[str] = None
|
|
12
|
+
errors: List[PipelineError] = Field(default_factory=list)
|
|
13
|
+
reasoning: List[Dict[str, Any]] = Field(default_factory=list)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Dict, Any, TYPE_CHECKING, List
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from nl2sql.pipeline.state import GraphState
|
|
6
|
+
|
|
7
|
+
from .schemas import (
|
|
8
|
+
RelationSchema,
|
|
9
|
+
ColumnSpec,
|
|
10
|
+
ExecutionDAG,
|
|
11
|
+
LogicalNode,
|
|
12
|
+
LogicalEdge,
|
|
13
|
+
)
|
|
14
|
+
from nl2sql.common.errors import PipelineError, ErrorSeverity, ErrorCode
|
|
15
|
+
from nl2sql.common.logger import get_logger
|
|
16
|
+
from nl2sql.context import NL2SQLContext
|
|
17
|
+
from .schemas import GlobalPlannerResponse
|
|
18
|
+
|
|
19
|
+
logger = get_logger("global_planner")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GlobalPlannerNode:
|
|
23
|
+
"""Generates a deterministic execution plan for aggregating sub-query results.
|
|
24
|
+
|
|
25
|
+
This node runs AFTER the Decomposer and BEFORE the SQL Agents.
|
|
26
|
+
It does not execute SQL, but produces a blueprint (ResultPlan) for the Aggregator.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, ctx: NL2SQLContext):
|
|
30
|
+
self.node_name = self.__class__.__name__.lower().replace("node", "")
|
|
31
|
+
|
|
32
|
+
def __call__(self, state: GraphState) -> Dict[str, Any]:
|
|
33
|
+
"""Executes the planning logic."""
|
|
34
|
+
decomposer_response = state.decomposer_response
|
|
35
|
+
sub_queries = decomposer_response.sub_queries if decomposer_response else []
|
|
36
|
+
combine_groups = decomposer_response.combine_groups if decomposer_response else []
|
|
37
|
+
post_combine_ops = decomposer_response.post_combine_ops if decomposer_response else []
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
nodes: List[LogicalNode] = []
|
|
41
|
+
edges: List[LogicalEdge] = []
|
|
42
|
+
node_index: Dict[str, LogicalNode] = {}
|
|
43
|
+
|
|
44
|
+
for sq in sub_queries:
|
|
45
|
+
schema = RelationSchema(
|
|
46
|
+
columns=[ColumnSpec(name=c.name, dtype=c.dtype) for c in sq.expected_schema]
|
|
47
|
+
)
|
|
48
|
+
node = LogicalNode(
|
|
49
|
+
node_id=sq.id,
|
|
50
|
+
kind="scan",
|
|
51
|
+
inputs=[],
|
|
52
|
+
output_schema=schema,
|
|
53
|
+
attributes={
|
|
54
|
+
"datasource_id": sq.datasource_id,
|
|
55
|
+
"intent": sq.intent,
|
|
56
|
+
"metrics": [m.model_dump() for m in sq.metrics],
|
|
57
|
+
"filters": [f.model_dump() for f in sq.filters],
|
|
58
|
+
"group_by": [g.model_dump() for g in sq.group_by],
|
|
59
|
+
"expected_schema": [c.model_dump() for c in sq.expected_schema],
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
nodes.append(node)
|
|
63
|
+
node_index[node.node_id] = node
|
|
64
|
+
|
|
65
|
+
combine_node_ids: Dict[str, str] = {}
|
|
66
|
+
for cg in combine_groups:
|
|
67
|
+
combine_id = f"combine_{cg.group_id}"
|
|
68
|
+
combine_node_ids[cg.group_id] = combine_id
|
|
69
|
+
input_ids = [i.subquery_id for i in cg.inputs]
|
|
70
|
+
output_schema = (
|
|
71
|
+
node_index[input_ids[0]].output_schema
|
|
72
|
+
if input_ids and input_ids[0] in node_index
|
|
73
|
+
else RelationSchema(columns=[])
|
|
74
|
+
)
|
|
75
|
+
node = LogicalNode(
|
|
76
|
+
node_id=combine_id,
|
|
77
|
+
kind="combine",
|
|
78
|
+
inputs=input_ids,
|
|
79
|
+
output_schema=output_schema,
|
|
80
|
+
attributes={
|
|
81
|
+
"operation": cg.operation,
|
|
82
|
+
"group_id": cg.group_id,
|
|
83
|
+
"inputs": [i.model_dump() for i in cg.inputs],
|
|
84
|
+
"join_keys": [jk.model_dump() for jk in cg.join_keys],
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
nodes.append(node)
|
|
88
|
+
node_index[node.node_id] = node
|
|
89
|
+
|
|
90
|
+
for inp in cg.inputs:
|
|
91
|
+
edges.append(
|
|
92
|
+
LogicalEdge(
|
|
93
|
+
edge_id=f"edge_{inp.subquery_id}_{combine_id}",
|
|
94
|
+
from_id=inp.subquery_id,
|
|
95
|
+
to_id=combine_id,
|
|
96
|
+
role=inp.role,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
for op in post_combine_ops:
|
|
101
|
+
target_combine_id = combine_node_ids.get(op.target_group_id)
|
|
102
|
+
if not target_combine_id:
|
|
103
|
+
raise ValueError(f"PostCombineOp references unknown combine group: {op.target_group_id}")
|
|
104
|
+
output_schema = RelationSchema(
|
|
105
|
+
columns=[ColumnSpec(name=c.name, dtype=c.dtype) for c in op.expected_schema]
|
|
106
|
+
)
|
|
107
|
+
kind_map = {
|
|
108
|
+
"filter": "post_filter",
|
|
109
|
+
"aggregate": "post_aggregate",
|
|
110
|
+
"project": "post_project",
|
|
111
|
+
"sort": "post_sort",
|
|
112
|
+
"limit": "post_limit",
|
|
113
|
+
}
|
|
114
|
+
node = LogicalNode(
|
|
115
|
+
node_id=op.op_id,
|
|
116
|
+
kind=kind_map[op.operation],
|
|
117
|
+
inputs=[target_combine_id],
|
|
118
|
+
output_schema=output_schema,
|
|
119
|
+
attributes={
|
|
120
|
+
"target_group_id": op.target_group_id,
|
|
121
|
+
"operation": op.operation,
|
|
122
|
+
"filters": [f.model_dump() for f in op.filters],
|
|
123
|
+
"metrics": [m.model_dump() for m in op.metrics],
|
|
124
|
+
"group_by": [g.model_dump() for g in op.group_by],
|
|
125
|
+
"order_by": [o.model_dump() for o in op.order_by],
|
|
126
|
+
"limit": op.limit,
|
|
127
|
+
"expected_schema": [c.model_dump() for c in op.expected_schema],
|
|
128
|
+
"metadata": op.metadata,
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
nodes.append(node)
|
|
132
|
+
node_index[node.node_id] = node
|
|
133
|
+
edges.append(
|
|
134
|
+
LogicalEdge(
|
|
135
|
+
edge_id=f"edge_{target_combine_id}_{op.op_id}",
|
|
136
|
+
from_id=target_combine_id,
|
|
137
|
+
to_id=op.op_id,
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
node_ids = {n.node_id for n in nodes}
|
|
142
|
+
for edge in edges:
|
|
143
|
+
if edge.from_id not in node_ids or edge.to_id not in node_ids:
|
|
144
|
+
raise ValueError(f"Edge references unknown node: {edge}")
|
|
145
|
+
|
|
146
|
+
execution_dag = ExecutionDAG(
|
|
147
|
+
nodes=sorted(nodes, key=lambda n: n.node_id),
|
|
148
|
+
edges=sorted(edges, key=lambda e: (e.from_id, e.to_id, e.role or "")),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
execution_dag.content_hash = self._hash_execution_dag(execution_dag)
|
|
152
|
+
execution_dag.dag_id = f"dag_{execution_dag.content_hash[:12]}"
|
|
153
|
+
|
|
154
|
+
result = GlobalPlannerResponse(execution_dag=execution_dag)
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"global_planner_response": result,
|
|
158
|
+
"reasoning": [{"node": self.node_name, "content": "Built explicit execution DAG."}],
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.error(f"GlobalPlanner failed: {e}")
|
|
163
|
+
return {
|
|
164
|
+
"global_planner_response": GlobalPlannerResponse(execution_dag=None),
|
|
165
|
+
"reasoning": [{"node": self.node_name, "content": f"Planning failed: {e}", "type": "error"}],
|
|
166
|
+
"errors": [
|
|
167
|
+
PipelineError(
|
|
168
|
+
node=self.node_name,
|
|
169
|
+
message=f"Global Plan generation failed: {str(e)}",
|
|
170
|
+
severity=ErrorSeverity.ERROR,
|
|
171
|
+
error_code=ErrorCode.PLANNER_FAILED
|
|
172
|
+
)
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
def _hash_execution_dag(self, dag: ExecutionDAG) -> str:
|
|
177
|
+
import hashlib
|
|
178
|
+
import json
|
|
179
|
+
|
|
180
|
+
payload = {
|
|
181
|
+
"nodes": [n.model_dump() for n in dag.nodes],
|
|
182
|
+
"edges": [e.model_dump() for e in dag.edges],
|
|
183
|
+
"version": dag.version,
|
|
184
|
+
}
|
|
185
|
+
data = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
186
|
+
return hashlib.sha256(data.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import List, Literal, Optional, Dict, Any
|
|
3
|
+
from pydantic import BaseModel, Field, model_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
JsonLiteral = str | int | float | bool | None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ColumnSpec(BaseModel):
|
|
10
|
+
name: str
|
|
11
|
+
dtype: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
class RelationSchema(BaseModel):
|
|
14
|
+
columns: List[ColumnSpec]
|
|
15
|
+
|
|
16
|
+
@model_validator(mode="after")
|
|
17
|
+
def validate_unique_columns(self):
|
|
18
|
+
names = [c.name for c in self.columns]
|
|
19
|
+
if len(names) != len(set(names)):
|
|
20
|
+
raise ValueError(f"Duplicate columns in schema: {names}")
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
class LogicalNode(BaseModel):
|
|
24
|
+
node_id: str
|
|
25
|
+
kind: Literal[
|
|
26
|
+
"scan",
|
|
27
|
+
"combine",
|
|
28
|
+
"post_filter",
|
|
29
|
+
"post_aggregate",
|
|
30
|
+
"post_project",
|
|
31
|
+
"post_sort",
|
|
32
|
+
"post_limit",
|
|
33
|
+
]
|
|
34
|
+
inputs: List[str] = Field(default_factory=list)
|
|
35
|
+
output_schema: RelationSchema
|
|
36
|
+
attributes: Dict[str, Any] = Field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LogicalEdge(BaseModel):
|
|
40
|
+
edge_id: str
|
|
41
|
+
from_id: str
|
|
42
|
+
to_id: str
|
|
43
|
+
role: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExecutionDAG(BaseModel):
|
|
47
|
+
dag_id: Optional[str] = None
|
|
48
|
+
content_hash: Optional[str] = None
|
|
49
|
+
nodes: List[LogicalNode]
|
|
50
|
+
edges: List[LogicalEdge]
|
|
51
|
+
layers: List[List[str]] = Field(default_factory=list)
|
|
52
|
+
version: str = "v1"
|
|
53
|
+
|
|
54
|
+
@model_validator(mode="after")
|
|
55
|
+
def populate_layers(self):
|
|
56
|
+
if not self.layers and self.nodes:
|
|
57
|
+
self.layers = self._layered_toposort(self.nodes, self.edges)
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _layered_toposort(
|
|
62
|
+
nodes: List[LogicalNode],
|
|
63
|
+
edges: List[LogicalEdge],
|
|
64
|
+
) -> List[List[str]]:
|
|
65
|
+
node_ids = {n.node_id for n in nodes}
|
|
66
|
+
indegree: Dict[str, int] = {n.node_id: 0 for n in nodes}
|
|
67
|
+
dependents: Dict[str, List[str]] = {n.node_id: [] for n in nodes}
|
|
68
|
+
|
|
69
|
+
for edge in edges:
|
|
70
|
+
if edge.from_id not in node_ids or edge.to_id not in node_ids:
|
|
71
|
+
continue
|
|
72
|
+
indegree[edge.to_id] += 1
|
|
73
|
+
dependents[edge.from_id].append(edge.to_id)
|
|
74
|
+
|
|
75
|
+
layers: List[List[str]] = []
|
|
76
|
+
ready = sorted([n_id for n_id, deg in indegree.items() if deg == 0])
|
|
77
|
+
processed = 0
|
|
78
|
+
|
|
79
|
+
while ready:
|
|
80
|
+
current_layer = ready
|
|
81
|
+
layers.append(current_layer)
|
|
82
|
+
processed += len(current_layer)
|
|
83
|
+
|
|
84
|
+
next_ready: List[str] = []
|
|
85
|
+
for node_id in current_layer:
|
|
86
|
+
for child in sorted(dependents.get(node_id, [])):
|
|
87
|
+
indegree[child] -= 1
|
|
88
|
+
if indegree[child] == 0:
|
|
89
|
+
next_ready.append(child)
|
|
90
|
+
ready = sorted(set(next_ready))
|
|
91
|
+
|
|
92
|
+
if processed != len(nodes):
|
|
93
|
+
raise ValueError("ExecutionDAG contains a cycle; layered topo sort failed.")
|
|
94
|
+
|
|
95
|
+
return layers
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class GlobalPlannerResponse(BaseModel):
|
|
99
|
+
execution_dag: ExecutionDAG
|
|
100
|
+
|
|
101
|
+
|