Skip to content

Commit 5669713

Browse files
ahrzbclaude
andcommitted
feat(codegen): Substrait front-end for the codegen serving engine
Add CodegenSubstraitFn, a second codegen backend that consumes a Substrait plan instead of SQL. The plan is translated into the existing _codegen plan IR and handed to the same backend-agnostic pipeline (optimize/validate/infer/ compile) and rt.* runtime — only the front-end differs. - _codegen_substrait/: Context (row schemas + static tables), consumer (Substrait -> _codegen IR: Read/Filter/Project, scalar exprs, positional field refs, function anchors, literals, casts), and CodegenSubstraitFn. - Reuse: extract CodegenFn._finalize so both the SQL and Substrait front-ends join at the plan IR; nothing downstream changes. - Input is a Substrait plan (bytes); from_sql() produces one via DataFusion for callers/tests. Table names are resolved case-insensitively (DataFusion folds them). Surfaces DataFusion can't serialize (e.g. UNNEST) defer cleanly. - Wire a third "substrait" backend into the differential harness; it passes the supported scalar/scan/filter/project surface and skips the rest. Scope: the basic relational/scalar surface. Joins, string builtins, CASE and transformers are deferred (skip, not fail). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 42b9f12 commit 5669713

9 files changed

Lines changed: 461 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ dependencies = [
99
"pyarrow>=19.0",
1010
"pydantic>=2.0,<3.0",
1111
"sqlglot>=30.12.0",
12+
"substrait>=0.29.0",
1213
]
1314

1415
[dependency-groups]

sql_transform/_codegen/engine.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,19 @@ def __init__(
227227
output_model: type[BaseModel] | None = None,
228228
) -> None:
229229
plan = cp.build_plan(sql)
230+
self._finalize(plan, row_tables, static_tables, output_model)
231+
232+
def _finalize(
233+
self,
234+
plan: cp.Plan,
235+
row_tables: dict,
236+
static_tables: dict,
237+
output_model: type[BaseModel] | None,
238+
) -> None:
239+
"""Shared pipeline from the plan IR onward — optimize, validate, infer
240+
types, build the output model + static lookups, compile. Both the SQL
241+
front-end (CodegenFn) and the Substrait front-end (CodegenSubstraitFn) join
242+
here; everything downstream of the plan IR is backend-agnostic."""
230243
plan, specs = cp.optimize(plan, set(static_tables))
231244

232245
row_schemas = {n: cp.schema_from_pydantic(m) for n, m in row_tables.items()}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Substrait front-end for the codegen serving engine.
2+
3+
Same idea as `sql_transform._codegen`, but the plan comes from a **Substrait
4+
plan** instead of SQL parsed by sqlglot. The Substrait plan is translated into
5+
the existing `_codegen` plan IR and handed to the same backend-agnostic pipeline
6+
(`CodegenFn._finalize`): optimize, validate, type-infer, compile, run. The
7+
`rt.*` runtime and the emitter are reused untouched.
8+
9+
References the Substrait plan against a `Context` (registered row-table schemas
10+
and static tables) — the plan carries only names/anchors/indices; the context
11+
resolves them.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from sql_transform._codegen.plan import UnsupportedInCodegen
17+
from sql_transform._codegen_substrait.context import Context
18+
from sql_transform._codegen_substrait.engine import CodegenSubstraitFn
19+
20+
__all__ = ["CodegenSubstraitFn", "Context", "UnsupportedInCodegen"]
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""Translate a Substrait plan into the existing `_codegen` plan IR.
2+
3+
Scope (the "basic part"): Read, Filter and the top-level Project, over scalar
4+
expressions — field references, literals, arithmetic/comparison/boolean scalar
5+
functions, a few string builtins, and casts. Anything else raises
6+
`UnsupportedInCodegen`, so the differential harness reports it as a deferred
7+
surface rather than a wrong answer.
8+
9+
Field references in Substrait are POSITIONAL (an index into the emitting rel's
10+
output). We thread an ordered list of `(table, column_name)` down the tree so
11+
each index resolves back to the `Column(table, name)` the IR/emitter expect.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from typing import Any
17+
18+
from substrait.proto import Plan
19+
20+
from sql_transform._codegen import plan as cp
21+
22+
# Substrait standard-function name -> IR binary-op name (matches plan._BINOPS).
23+
_BINOP = {
24+
"add": "add",
25+
"subtract": "sub",
26+
"multiply": "mul",
27+
"divide": "div",
28+
"modulus": "mod",
29+
"equal": "eq",
30+
"not_equal": "neq",
31+
"lt": "lt",
32+
"gt": "gt",
33+
"lte": "lte",
34+
"gte": "gte",
35+
"and": "and",
36+
"or": "or",
37+
}
38+
# Substrait scalar function -> IR builtin name (matches engine._BUILTINS).
39+
_FUNC = {
40+
"upper": "upper",
41+
"lower": "lower",
42+
"abs": "abs",
43+
"coalesce": "coalesce",
44+
}
45+
# Substrait cast target type field -> IR base type.
46+
_CAST = {
47+
"string": cp.STR,
48+
"i64": cp.INT,
49+
"i32": cp.INT,
50+
"fp64": cp.FLOAT,
51+
"fp32": cp.FLOAT,
52+
"bool": cp.BOOL,
53+
}
54+
55+
56+
def consume(plan_bytes: bytes, table_names: Any = ()) -> cp.Plan:
57+
"""Substrait plan bytes -> `cp.Plan`. Column names come from the plan's own
58+
Read schemas. `table_names` are the registered names (from the Context): a
59+
Substrait Read folds its table name to lowercase (DataFusion catalog
60+
behaviour), so we resolve it back case-insensitively."""
61+
sp = Plan()
62+
sp.ParseFromString(plan_bytes)
63+
64+
funcs: dict[int, str] = {}
65+
for ext in sp.extensions:
66+
if ext.HasField("extension_function"):
67+
funcs[ext.extension_function.function_anchor] = ext.extension_function.name
68+
69+
if not sp.relations:
70+
raise cp.UnsupportedInCodegen("Substrait plan has no relations")
71+
root = sp.relations[0].root
72+
aliases = list(root.names)
73+
tmap = {n.lower(): n for n in table_names}
74+
return _project(root.input, aliases, funcs, tmap)
75+
76+
77+
def _project(
78+
rel: Any, aliases: list[str], funcs: dict[int, str], tmap: dict[str, str]
79+
) -> cp.Plan:
80+
"""The top rel must be a Project; peel it into `Plan(projection, input)`."""
81+
if not rel.HasField("project"):
82+
raise cp.UnsupportedInCodegen(
83+
f"expected a top-level Project, got {rel.WhichOneof('rel_type')}"
84+
)
85+
proj = rel.project
86+
input_rel, cols = _rel(proj.input, funcs, tmap)
87+
88+
# Substrait Project APPENDS its computed expressions after the input columns;
89+
# `emit.output_mapping` (when present) selects which of that pool is output.
90+
pool: list[Any] = [cp.Column(t, n) for t, n in cols]
91+
pool += [_expr(e, cols, funcs) for e in proj.expressions]
92+
93+
mapping = (
94+
list(proj.common.emit.output_mapping)
95+
if proj.common.HasField("emit")
96+
else list(range(len(pool)))
97+
)
98+
if aliases and len(aliases) != len(mapping):
99+
raise cp.UnsupportedInCodegen("Substrait output name/column count mismatch")
100+
101+
projection = []
102+
for i, idx in enumerate(mapping):
103+
alias = aliases[i] if aliases else _col_name(pool[idx], i)
104+
projection.append((alias, pool[idx]))
105+
return cp.Plan(projection, input_rel)
106+
107+
108+
def _col_name(expr: Any, i: int) -> str:
109+
return expr.name if isinstance(expr, cp.Column) else f"col{i}"
110+
111+
112+
def _rel(
113+
rel: Any, funcs: dict[int, str], tmap: dict[str, str]
114+
) -> tuple[Any, list[tuple[str, str]]]:
115+
"""Translate a relational node, returning (IR rel, ordered [(table, col)])."""
116+
kind = rel.WhichOneof("rel_type")
117+
if kind == "read":
118+
return _read(rel.read, tmap)
119+
if kind == "filter":
120+
inner, cols = _rel(rel.filter.input, funcs, tmap)
121+
pred = _expr(rel.filter.condition, cols, funcs)
122+
return cp.Filter(inner, pred), cols
123+
raise cp.UnsupportedInCodegen(f"Substrait relation '{kind}' is not supported yet")
124+
125+
126+
def _read(read: Any, tmap: dict[str, str]) -> tuple[Any, list[tuple[str, str]]]:
127+
if not read.HasField("named_table"):
128+
raise cp.UnsupportedInCodegen("only named-table reads are supported")
129+
folded = read.named_table.names[-1]
130+
table = tmap.get(folded.lower(), folded)
131+
names = list(read.base_schema.names)
132+
# A projection selects a column subset (by index into base_schema), in order.
133+
if read.HasField("projection"):
134+
picked = [it.field for it in read.projection.select.struct_items]
135+
names = [names[i] for i in picked]
136+
return cp.TableScan(table), [(table, n) for n in names]
137+
138+
139+
def _expr(e: Any, cols: list[tuple[str, str]], funcs: dict[int, str]) -> Any:
140+
kind = e.WhichOneof("rex_type")
141+
if kind == "selection":
142+
idx = e.selection.direct_reference.struct_field.field
143+
table, name = cols[idx]
144+
return cp.Column(table, name)
145+
if kind == "literal":
146+
return cp.Literal(_literal(e.literal))
147+
if kind == "cast":
148+
target = e.cast.type.WhichOneof("kind")
149+
if target not in _CAST:
150+
raise cp.UnsupportedInCodegen(f"cast to Substrait type '{target}'")
151+
return cp.Cast(_expr(e.cast.input, cols, funcs), _CAST[target])
152+
if kind == "scalar_function":
153+
return _scalar(e.scalar_function, cols, funcs)
154+
raise cp.UnsupportedInCodegen(f"Substrait expression '{kind}' is not supported yet")
155+
156+
157+
def _scalar(sf: Any, cols: list[tuple[str, str]], funcs: dict[int, str]) -> Any:
158+
name = funcs.get(sf.function_reference)
159+
if name is None:
160+
raise cp.UnsupportedInCodegen(
161+
f"unknown Substrait function anchor {sf.function_reference}"
162+
)
163+
args = [_expr(a.value, cols, funcs) for a in sf.arguments if a.HasField("value")]
164+
if name == "not":
165+
return cp.Not(args[0])
166+
if name in _BINOP:
167+
if len(args) != 2:
168+
raise cp.UnsupportedInCodegen(f"{name} with {len(args)} args")
169+
return cp.BinaryOp(_BINOP[name], args[0], args[1])
170+
if name in _FUNC:
171+
return cp.Func(_FUNC[name], args)
172+
raise cp.UnsupportedInCodegen(f"Substrait function '{name}' is not supported yet")
173+
174+
175+
def _literal(lit: Any) -> Any:
176+
kind = lit.WhichOneof("literal_type")
177+
if kind == "null":
178+
return None
179+
if kind in ("fp64", "fp32"):
180+
return float(getattr(lit, kind))
181+
if kind in ("i64", "i32", "i16", "i8"):
182+
return int(getattr(lit, kind))
183+
if kind == "boolean":
184+
return bool(lit.boolean)
185+
if kind == "string":
186+
return lit.string
187+
raise cp.UnsupportedInCodegen(f"Substrait literal '{kind}' is not supported yet")
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""The resolution environment for a Substrait plan.
2+
3+
A Substrait plan is portable: it names tables, references functions by anchor,
4+
and columns by position. It carries no schemas or table data. The `Context`
5+
supplies what the plan omits — the row-table schemas (as Pydantic models, the
6+
same shape `CodegenFn`/`InferFn` take) and the static tables (Arrow tables the
7+
lookup joins index). Registered in the DataFusion session that *produces* the
8+
plan and consulted again when we *consume* it.
9+
10+
Transformers are intentionally out of scope for now (see package docstring).
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from dataclasses import dataclass, field
16+
from typing import Any
17+
18+
19+
@dataclass(frozen=True)
20+
class Context:
21+
row_tables: dict[str, Any] = field(default_factory=dict)
22+
"""name -> Pydantic model class (the row schema)."""
23+
static_tables: dict[str, Any] = field(default_factory=dict)
24+
"""name -> pyarrow.Table (frozen fit-state / lookup tables)."""
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""`CodegenSubstraitFn` — a codegen serving engine driven by a Substrait plan.
2+
3+
Takes a `Context` (registered row-table schemas + static tables) and a Substrait
4+
plan; translates the plan into the `_codegen` IR (via `consumer.consume`) and
5+
reuses `CodegenFn`'s backend-agnostic pipeline and `infer()` unchanged.
6+
7+
`from_sql` is the conditional the caller uses to pass SQL instead of a plan: it
8+
produces the Substrait plan through DataFusion (registering the context's tables)
9+
and feeds it to the same consumer — so SQL and a hand-supplied plan are one path.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import datafusion
15+
import pyarrow as pa
16+
from datafusion import substrait as dss
17+
from pydantic import BaseModel
18+
19+
from sql_transform._codegen import plan as cp
20+
from sql_transform._codegen.engine import CodegenFn
21+
from sql_transform._codegen_substrait.consumer import consume
22+
from sql_transform._codegen_substrait.context import Context
23+
24+
_ARROW = {
25+
cp.INT: pa.int64(),
26+
cp.FLOAT: pa.float64(),
27+
cp.STR: pa.string(),
28+
cp.BOOL: pa.bool_(),
29+
}
30+
31+
32+
class CodegenSubstraitFn(CodegenFn):
33+
"""Codegen engine whose plan comes from Substrait, not SQL. Same `infer()`."""
34+
35+
def __init__(
36+
self,
37+
context: Context,
38+
substrait_plan: bytes,
39+
output_model: type[BaseModel] | None = None,
40+
) -> None:
41+
table_names = set(context.row_tables) | set(context.static_tables)
42+
plan = consume(substrait_plan, table_names)
43+
self._finalize(plan, context.row_tables, context.static_tables, output_model)
44+
45+
@classmethod
46+
def from_sql(
47+
cls,
48+
sql: str,
49+
context: Context,
50+
output_model: type[BaseModel] | None = None,
51+
) -> CodegenSubstraitFn:
52+
"""Produce the Substrait plan from SQL via DataFusion, then consume it."""
53+
return cls(context, sql_to_substrait(sql, context), output_model)
54+
55+
56+
def sql_to_substrait(sql: str, context: Context) -> bytes:
57+
"""Serialize `sql` to a Substrait plan, registering the context's tables in a
58+
fresh DataFusion session so column names/types and table names resolve."""
59+
ctx = datafusion.SessionContext()
60+
for name, model in context.row_tables.items():
61+
empty = pa.RecordBatch.from_pylist([], schema=_arrow_schema(model))
62+
ctx.register_record_batches(name, [[empty]])
63+
for name, table in context.static_tables.items():
64+
ctx.register_record_batches(name, [table.to_batches()])
65+
try:
66+
return dss.Serde.serialize_bytes(sql, ctx)
67+
except Exception as e: # noqa: BLE001 -- DataFusion raises a bare Exception
68+
# DataFusion's own Substrait producer can't express this query (e.g.
69+
# UNNEST). That surface is unreachable through Substrait — defer it so the
70+
# differential harness skips rather than fails, matching codegen's pattern.
71+
raise cp.UnsupportedInCodegen(
72+
f"DataFusion cannot serialize this query to Substrait: {str(e)[:120]}"
73+
) from e
74+
75+
76+
def _arrow_schema(model: type[BaseModel]) -> pa.Schema:
77+
fields = []
78+
for name, ft in cp.schema_from_pydantic(model).items():
79+
arrow_t = _ARROW.get(ft.base)
80+
if arrow_t is None:
81+
raise cp.UnsupportedInCodegen(
82+
f"cannot register column '{name}': non-scalar type not supported yet"
83+
)
84+
fields.append(pa.field(name, arrow_t, nullable=ft.nullable))
85+
return pa.schema(fields)

0 commit comments

Comments
 (0)