GeneratorNode
Overview
- Converts the AST plan into SQL using
sqlglot. - Enforces adapter row limits and dialect selection.
- Sits between
LogicalValidatorNodeandExecutorNode. - Class:
GeneratorNode - Source:
packages/nl2sql/src/nl2sql/pipeline/nodes/generator/node.py
Responsibilities
- Convert
PlanModelexpressions intosqlglotexpressions. - Build a SQL query with ordered selects, joins, filters, groupings, and limits.
- Apply adapter dialect and row limit.
Position in Execution Graph
Upstream:
- LogicalValidatorNode
Downstream:
- ExecutorNode
Trigger conditions: - Executed when logical validation passes.
flowchart LR
Validator[LogicalValidatorNode] --> Generator[GeneratorNode] --> Executor[ExecutorNode]
Inputs
From SubgraphExecutionState:
ast_planner_response.plan(required)sub_query.datasource_id(required)
From Runtime:
ds_registry(adapter dialect and row limits)
Validation performed:
- Raises if datasource ID or plan is missing.
Outputs
Mutations to SubgraphExecutionState:
generator_response(GeneratorResponsewithsql_draft, androw_cap: the adapter'srow_limitwhen it, not the plan's ownLIMIT, bounds the query;Nonewhen the plan asked for that many rows or fewer). The aggregator uses it to tell a sub-query cut short by the cap from a complete one (see EngineAggregatorNode).reasoningerrorson failure
Side effects:
- Adapter registry access for dialect/limits.
Internal Flow (Step-by-Step)
- Validate presence of datasource ID and plan.
- Resolve adapter and dialect.
- Compute effective limit from plan/adapter row limit:
row_limitis the datasource'srow_limitbounded by thesettings.results.row_limitceiling (100,000), or the ceiling when the datasource sets none; the limit ismin(plan.limit or row_limit, row_limit). When the cap is what bounds the query, record it asrow_cap. - Traverse AST with
SqlVisitorto buildsqlglotexpression tree. - Every operator becomes the node sqlglot's parser would build (
+->exp.Add,*->exp.Mul,IS NOT->NOT ... IS ..., and so on), wherever it is nested. An operator with no mapping raises; it is never rendered as a function named after the operator. - An operand that is itself an operator is parenthesised, because sqlglot prints a hand-built tree without adding the parentheses its parser would have seen (
(a + b) * cwould otherwise print asa + b * c). /is sqlglot's true division, so on SQLite it renders asCAST(a AS REAL) / brather than integer division.||is string concatenation, rendered as the dialect's own:a || bon SQLite and Postgres,CONCAT(a, b)on MySQL,a + bon T-SQL. A+with a string operand (a non-numeric string literal, or another concatenation) renders the same way, because on SQLite'Jane' + ' 'is numeric addition and returns0. A+between two columns stays addition: their types are not known here.- Select aliases and column names are quoted, the dialect's way, only when they are not bare identifiers. The decomposer names result columns in plain words and the planner must use those names as aliases, so an alias such as
sales support agentrenders asAS "sales support agent"(andAS `sales support agent`on MySQL), in the select list and in theORDER BYtie-breakers. - A
funcexpr withdistinct: truewraps its arguments inexp.Distinct, so{"kind": "func", "func_name": "COUNT", "is_aggregate": true, "distinct": true, "args": [x]}renders asCOUNT(DISTINCT x).PlanModel.distinct: truerendersSELECT DISTINCT. - A
funcexpr is built withsqlglot.exp.func(name, *args), so a function sqlglot knows becomes its typed node and each dialect renders it its own way (LENGTHisLENon T-SQL,COUNTisCOUNT_BIGon T-SQL). A name sqlglot does not know (such asSTRFTIME) stays as written; the logical validator has already limited names toast_planner.functions.ALLOWED_FUNCTIONS. A function sqlglot builds as an operator (MOD(a, b)isa % b) keeps its operands' grouping. - Dates are portable:
DATE_PART(unit, x)becomesCAST(EXTRACT(unit FROM x) AS INT), an integer, andDATE_TRUNC(unit, x)becomesexp.TimeToStr(exp.TimestampTrunc(x, unit), '%Y-%m-%d'), aYYYY-MM-DDstring, with unityear,quarter,monthorday.YEAR(x),QUARTER(x),MONTH(x),DAY(x),EXTRACT(unit, x)andDATE_TRUNC(x, unit)written by a model are read the same way. The generator writes no dialect SQL for them: the adapter renders the tree (next step). - Attach joins. The first table in
plan.tablesis theFROMtable.left_alias/right_aliasdo not say which table is new: each join attaches whichever side is not yet in scope, taking the first listed join that touches a table already in scope, so joins may be listed in any order. When the new table is the join's left side,left/rightouter joins are mirrored so the same table is preserved. Joined tables keep theirschema_name/databasequalifiers. - Order the rows completely. The plan's
ORDER BYterms come first, with their direction. Every other selected column follows as an ascending tie-breaker, in select order. With noORDER BYin the plan, the query is ordered by every selected column. This does not change what the query means, but without it theLIMITbelow could keep a different subset of rows on each run. - An aliased item is ordered by its alias (
ORDER BY genre, track_sales), which stays valid for aggregates underGROUP BY. Terms are never positions. - A select item already used as an
ORDER BYterm, by expression or by alias, is not repeated. - Constant items are skipped: they order nothing, and a bare number would be read as a position.
- Every term, the plan's own and the tie-breakers, keeps the dialect's default NULL placement for its direction, so no
NULLS FIRST/NULLS LASTclause is rendered (nor, on T-SQL and MySQL, aCASE WHEN ... IS NULLemulation, which is invalid when the term is an alias). For example, a plan ordering bycityascending andc.Countrydescending rendersORDER BY city ASC, c.Country DESC, c.LastNameon SQLite, Postgres, T-SQL and MySQL alike. - The logical validator runs on the plan before this step and never sees the tie-breakers.
- Apply the effective limit, then the plan's
offsetwhen it is above zero: rows 11 to 20 arelimit: 10, offset: 10. The limit is already capped, so a page is never longer than the row cap, androw_capis reported exactly as without an offset. sqlglot renders the pair in each dialect's own form (LIMIT 10 OFFSET 10;OFFSET 10 ROWS FETCH FIRST 10 ROWS ONLYon T-SQL). An offset only means something in an order, so a plan with anoffsetand noorder_by, or a negativeoffset, is refused here with aValueError; the logical validator runs this builder, so the planner is asked to fix it before generation (see Buildability). The tie-breakers of step 6 then make the page the same on every run. - Hand the finished tree to the adapter's
render_sql(expression)(see the SDK). An adapter without the hook is rendered withquery.sql(dialect=adapter.get_dialect()). The SQLite adapter rewrites the date nodes withSTRFTIME/DATE(x, 'start of ...'); the others use sqlglot's rendering. - Return
GeneratorResponsewith SQL and reasoning. - On exception, emit
SQL_GEN_FAILED.
Contracts & Interfaces
Implements a LangGraph node callable:
Key contracts:
GeneratorResponsePlanModel/Expr
Determinism Guarantees
- Deterministic for a fixed AST and adapter dialect.
- Ordering is the plan's own list order: the first select item is the first column, the first table is the
FROMtable. - Row order is total: every selected column is an
ORDER BYterm (see step 6), so the same plan run against the same data returns the same rows in the same order, even whenLIMITtruncates the result.
Error Handling
Emits PipelineError with:
SQL_GEN_FAILED
A malformed join graph is a planning error, not something to guess around. SQL_GEN_FAILED is raised when a join names an undeclared alias, when a join connects two tables that are both already in scope, when a join cannot be reached from the FROM table, or when a declared table is never joined.
In practice the planner should never see this. There is no retry edge after the
generator, so a SQL_GEN_FAILED ends the sub-query. LogicalValidatorNode
therefore runs _build_query() itself and reports the same ValueError as a
retryable INVALID_PLAN_STRUCTURE one node earlier — see
Buildability.
The checks below stay where they are: they are the single definition of the
rules, and the validator calling this code is what keeps the two in step.
SUPPORTED_BINARY_OPS is exported for the same reason — it is the set
SqlVisitor._visit_binary renders, and the validator rejects anything outside
it before the plan gets here.
Logs exceptions via logger.exception.
Retry + Idempotency
- No internal retry logic.
- Idempotent for a fixed plan.
Performance Characteristics
- In‑memory AST traversal and SQL rendering.
- Cost grows with AST size.
Observability
- Logger:
generator - Adds reasoning entries with the generated SQL.
Configuration
- Adapter
row_limitanddialectfrom datasource config.
Extension Points
- Extend
SqlVisitorfor new expression kinds. - Replace node in
build_sql_agent_graph()for alternate generation strategies.
Known Limitations
- No SQL optimization beyond AST structure.
- The plan language has no subqueries or window functions.
- No dialect fallback if adapter misconfigured.
Related Code
packages/nl2sql/src/nl2sql/pipeline/nodes/generator/node.py