Skip to content

Commit d8e56e9

Browse files
ahrzbclaude
andauthored
feat(codegen): struct/list containers — TASK-29 Phase B (Tasks 1–2 of 4) (#4)
* docs: TASK-29 Phase B plan — codegen struct/list containers Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(codegen): struct/list column passthrough — enable codegen for container output (TASK-29B-1) Move struct and list column projection from deferred (UnsupportedInCodegen) to committed surface. The output model + marshalling infrastructure already handles struct/list Pydantic models. This unlocks struct/list passthrough for codegen, matching native engine parity. DataFusion oracle validates. Changes: - sql_transform/_codegen/engine.py: Remove the guard block rejecting struct/list containers - tests/test_codegen_coverage.py: Move struct/list passthroughs from _DEFERRED to _COMMITTED - sql_transform/_codegen/engine_test.py: Update test to verify struct columns now pass through Test results: 495 passed (+3), 11 skipped (-3) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(codegen): drop unused import, reformat test, correct _to_native docstring (TASK-29B-1 review) - Remove unused UnsupportedInCodegen import from engine_test.py (F401) - Fix stray blank lines in engine_test.py via ruff format - Update _to_native docstring: struct columns now reach projected output via passthrough, not only via JOIN keys; removed false claim about UnsupportedInCodegen rejection Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(codegen): struct/list construction (named_struct/struct/array) — TASK-29 Phase B Adds StructExpr/ListExpr IR: named_struct(...), struct(...)/STRUCT(...), and array(...)/make_array(...)/[...] now build a Python dict/list at runtime instead of raising UnsupportedInCodegen. _DEFERRED_FUNCS shrinks to ("unnest",). Also tightens the Func emit path in engine.py: an unrecognized function name (notably a transformer call, __tfm_N__) now raises UnsupportedInCodegen instead of a generic ValueError -- this path was previously unreachable because named_struct short-circuited before descending into its arguments; completing named_struct exposed it. Updated plan_test.py's stale "named_struct defers" assertion accordingly and added a focused unit test for the new construction IR. * fix(codegen): widen mixed-numeric list elements + narrow unknown-func defer to __tfm_ (TASK-29B-2 review) - infer_type's ListExpr arm now unifies element types via _common_base (same helper COALESCE uses), so make_array(int, float) types as list[float] and pydantic widens the int element at model_validate (measured: no emission-side change needed). - _emit_expr's Func branch narrows the UnsupportedInCodegen defer to __tfm_ transformer placeholders only; every other unknown function name (e.g. foobar(x), invalid SQL) keeps the original hard ValueError instead of being silently skipped by the differential harness. * refactor(codegen): drop dead struct/array Anonymous branches (TASK-29B review) struct(...) parses as exp.Struct and array(...) as exp.Array, both handled by earlier _convert_expr arms; only make_array reaches the Anonymous arm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(codegen): cover positional/named struct + make_array; fix stale comments (PR#4 review) Auto-review flagged three committed codegen construction shapes with no value test: positional struct(a,b) -> c0/c1, named struct(a AS x,...) (exp.PropertyEQ), and make_array(...). All three match the DataFusion oracle on codegen but native has no dispatch for them, so they xfail_on_native -- same parity-gap pattern as the mixed-numeric widening test. Native gaps want their own tickets. Also fixed two stale comments: plan.py claimed named_struct parses to exp.Struct (it's exp.Anonymous), and test_transformer_case claimed codegen defers on named_struct (it defers on the __tfm_0__ transformer call; named_struct is supported now). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 28c46fc commit d8e56e9

9 files changed

Lines changed: 682 additions & 25 deletions

File tree

docs/superpowers/plans/2026-07-22-codegen-deferred-phase-b-containers.md

Lines changed: 490 additions & 0 deletions
Large diffs are not rendered by default.

sql_transform/_codegen/engine.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ def _emit_expr(e: Any, env: dict) -> str:
9494
if isinstance(e, cp.Func):
9595
fn = _BUILTINS.get(e.name)
9696
if fn is None:
97+
if e.name.startswith("__tfm_"):
98+
# A transformer placeholder call: codegen has no transformer
99+
# support (native-only, TASK-34) -- deferred surface, not an error.
100+
raise UnsupportedInCodegen(
101+
f"{e.name}() is not supported in codegen yet"
102+
)
97103
raise ValueError(f"Unknown function: {e.name}")
98104
return f"{fn}({', '.join(_emit_expr(a, env) for a in e.args)})"
99105
if isinstance(e, cp.Case):
@@ -104,6 +110,11 @@ def _emit_expr(e: Any, env: dict) -> str:
104110
f"else {out})"
105111
)
106112
return out
113+
if isinstance(e, cp.StructExpr):
114+
items = ", ".join(f"{name!r}: {_emit_expr(v, env)}" for name, v in e.fields)
115+
return f"{{{items}}}"
116+
if isinstance(e, cp.ListExpr):
117+
return f"[{', '.join(_emit_expr(x, env) for x in e.items)}]"
107118
raise UnsupportedInCodegen(f"cannot compile {type(e).__name__}")
108119

109120

@@ -173,9 +184,8 @@ def looked_up(inner: dict, i: int) -> None:
173184

174185
def _to_native(v: Any) -> Any:
175186
"""Recursively unwrap a row's Pydantic struct/list value into plain
176-
dicts/lists. Struct columns can't reach a projected output (rejected as
177-
UnsupportedInCodegen below), but a JOIN ON key CAN reference one -- and
178-
two structurally-identical struct columns from different row tables are
187+
dicts/lists for struct/list passthrough or JOIN key normalization.
188+
Two structurally-identical struct columns from different row tables are
179189
two different synthesized Pydantic classes, so BaseModel.__eq__ (which
180190
checks type identity) would wrongly call them unequal. Plain dicts
181191
compare structurally, matching DataFusion/rt.val_eq's semantics."""
@@ -238,12 +248,6 @@ def __init__(
238248
schemas = validation.effective_schemas
239249

240250
inferred = [(alias, cp.infer_type(e, schemas)) for alias, e in plan.projection]
241-
for alias, ft in inferred:
242-
if cp.is_container(ft.base):
243-
raise UnsupportedInCodegen(
244-
f"column '{alias}' is a struct/list, which codegen does not "
245-
"support yet"
246-
)
247251

248252
if output_model is None:
249253
self.output_model = create_model(

sql_transform/_codegen/engine_test.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import pytest
1111
from pydantic import BaseModel
1212

13-
from sql_transform._codegen import CodegenFn, UnsupportedInCodegen
13+
from sql_transform._codegen import CodegenFn
1414

1515

1616
class Row(BaseModel):
@@ -115,15 +115,18 @@ def test_int_division_by_zero_raises_value_error():
115115
fn.infer({"t": [Row(a=1)]})
116116

117117

118-
def test_container_columns_are_deferred_not_silently_wrong():
118+
def test_container_columns_struct_passthrough():
119119
class Inner(BaseModel):
120120
x: int
121121

122122
class WithStruct(BaseModel):
123123
s: Inner
124124

125-
with pytest.raises(UnsupportedInCodegen):
126-
CodegenFn("SELECT s AS out FROM t", {"t": WithStruct}, {})
125+
# Struct columns now pass through (no longer deferred)
126+
fn = CodegenFn("SELECT s AS out FROM t", {"t": WithStruct}, {})
127+
result = fn.infer(t=[WithStruct(s=Inner(x=42))])
128+
assert len(result) == 1
129+
assert result[0].out.x == 42
127130

128131

129132
def test_generated_source_is_available_for_debugging():

sql_transform/_codegen/plan.py

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,16 @@ class Cast:
189189
target: str
190190

191191

192+
@dataclass
193+
class StructExpr:
194+
fields: list # list[tuple[str, expr]] -- key order significant
195+
196+
197+
@dataclass
198+
class ListExpr:
199+
items: list # list[expr]
200+
201+
192202
@dataclass
193203
class Case:
194204
arms: list # list[tuple[cond, result]]
@@ -263,7 +273,7 @@ class Plan:
263273
exp.Abs: "abs",
264274
exp.Round: "round",
265275
}
266-
_DEFERRED_FUNCS = ("named_struct", "struct", "unnest", "make_array")
276+
_DEFERRED_FUNCS = ("unnest",)
267277

268278
# Measured: sqlglot spreads variadic args differently PER FUNCTION, so each needs
269279
# its own extraction. Concat -> this=None, args all in .expressions
@@ -348,10 +358,13 @@ def _convert_expr(e: exp.Expression) -> Any:
348358
return BinaryOp(op, _convert_expr(e.this), _convert_expr(e.expression))
349359
if isinstance(e, exp.Cast):
350360
return Cast(_convert_expr(e.this), _cast_target(e.to.sql()))
351-
if isinstance(e, (exp.Struct, exp.Array)):
352-
raise UnsupportedInCodegen(
353-
"struct/list construction is not supported in codegen yet"
354-
)
361+
if isinstance(e, exp.Struct):
362+
# Authored struct(...) form: positional `struct(a, b)` (bare exprs) or
363+
# named `struct(a AS x, ...)` (exp.PropertyEQ). named_struct(...) is a
364+
# separate shape -- it parses as exp.Anonymous, handled below.
365+
return _convert_struct(e.expressions)
366+
if isinstance(e, exp.Array):
367+
return ListExpr([_convert_expr(a) for a in e.expressions])
355368
if isinstance(e, exp.Unnest):
356369
# Measured: UNNEST(...) parses as its own exp.Unnest class, not
357370
# exp.Anonymous -- the _DEFERRED_FUNCS name-matching branch below never
@@ -379,12 +392,44 @@ def _convert_expr(e: exp.Expression) -> Any:
379392
return Func(name, [_convert_expr(a) for a in extract(e)])
380393
if isinstance(e, exp.Anonymous):
381394
name = e.name.lower()
395+
if name == "named_struct":
396+
return _named_struct(e.expressions)
397+
# struct(...) parses as exp.Struct and array(...) as exp.Array (handled
398+
# above); only make_array reaches the Anonymous arm.
399+
if name == "make_array":
400+
return ListExpr([_convert_expr(a) for a in e.expressions])
382401
if name in _DEFERRED_FUNCS:
383402
raise UnsupportedInCodegen(f"{name}() is not supported in codegen yet")
384403
return Func(name, [_convert_expr(a) for a in e.expressions])
385404
raise ValueError(f"Unsupported expression: {e.sql()}")
386405

387406

407+
def _named_struct(args: list) -> StructExpr:
408+
if len(args) % 2 != 0:
409+
raise ValueError("named_struct expects (key, value) pairs")
410+
fields = []
411+
for i in range(0, len(args), 2):
412+
key = args[i]
413+
if not (isinstance(key, exp.Literal) and key.is_string):
414+
raise ValueError("named_struct field names must be string literals")
415+
fields.append((key.this, _convert_expr(args[i + 1])))
416+
return StructExpr(fields)
417+
418+
419+
def _convert_struct(exprs: list) -> StructExpr:
420+
# STRUCT(...) authored form: sqlglot wraps each field as exp.PropertyEQ
421+
# (aliased 'name := value') or a bare expr (positional c0, c1, ...).
422+
fields = []
423+
for i, item in enumerate(exprs):
424+
if isinstance(item, exp.PropertyEQ):
425+
fields.append((item.this.name, _convert_expr(item.expression)))
426+
elif isinstance(item, exp.Alias):
427+
fields.append((item.alias, _convert_expr(item.this)))
428+
else:
429+
fields.append((f"c{i}", _convert_expr(item)))
430+
return StructExpr(fields)
431+
432+
388433
def _convert_literal(e: exp.Literal) -> Any:
389434
if e.is_string:
390435
return e.this
@@ -722,6 +767,12 @@ def _validate_expr(e: Any, resolved, row_schemas, static_schemas, used) -> None:
722767
elif isinstance(e, Func):
723768
for a in e.args:
724769
_validate_expr(a, resolved, row_schemas, static_schemas, used)
770+
elif isinstance(e, StructExpr):
771+
for _, v in e.fields:
772+
_validate_expr(v, resolved, row_schemas, static_schemas, used)
773+
elif isinstance(e, ListExpr):
774+
for x in e.items:
775+
_validate_expr(x, resolved, row_schemas, static_schemas, used)
725776

726777

727778
_STR_FUNCS = frozenset({"upper", "lower", "trim", "substr", "substring"})
@@ -763,6 +814,22 @@ def infer_type(e: Any, schemas: dict) -> FieldType:
763814
nullable = (not e.has_else) or any(t.nullable for t in branch_types)
764815
base = _common_base(branch_types)
765816
return FieldType(base, nullable)
817+
if isinstance(e, StructExpr):
818+
fields = tuple((name, infer_type(v, schemas)) for name, v in e.fields)
819+
return FieldType(StructBase(fields), False)
820+
if isinstance(e, ListExpr):
821+
elem_types = [infer_type(x, schemas) for x in e.items]
822+
if not elem_types:
823+
elem = FieldType(OTHER, True)
824+
else:
825+
# Unify element bases the same way COALESCE does (numeric int/float
826+
# -> float), so e.g. make_array(int_col, float_col) types as
827+
# list<float> -- matching DataFusion, which widens the int element
828+
# at runtime (measured: make_array(1, 2.5) -> [1.0, 2.5]).
829+
elem = FieldType(
830+
_common_base(elem_types), any(t.nullable for t in elem_types)
831+
)
832+
return FieldType(ListBase(elem), False)
766833
if isinstance(e, Func):
767834
return _function_type(e.name, [infer_type(a, schemas) for a in e.args])
768835
raise UnsupportedInCodegen(f"cannot infer the type of {type(e).__name__}")

sql_transform/_codegen/plan_test.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,19 @@ def test_build_plan_not_and_logic():
236236
)
237237

238238

239-
def test_build_plan_defers_containers():
239+
def test_build_plan_defers_unnest():
240240
with pytest.raises(cp.UnsupportedInCodegen):
241241
cp.build_plan("SELECT unnest(a) AS x FROM t")
242-
with pytest.raises(cp.UnsupportedInCodegen):
243-
cp.build_plan("SELECT named_struct('k', a) AS x FROM t")
242+
243+
244+
def test_build_plan_constructs_struct_and_list():
245+
# TASK-29 Phase B: named_struct/array construction, previously deferred.
246+
plan = cp.build_plan("SELECT named_struct('k', a) AS x FROM t")
247+
assert plan.projection == [("x", cp.StructExpr([("k", cp.Column(None, "a"))]))]
248+
plan = cp.build_plan("SELECT array(a, a) AS x FROM t")
249+
assert plan.projection == [
250+
("x", cp.ListExpr([cp.Column(None, "a"), cp.Column(None, "a")]))
251+
]
244252

245253

246254
def _schemas():

tests/test_codegen_coverage.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@
8585
"s": static({"k": "int", "v": "float"}, [{"k": 1, "v": 1.0}]),
8686
},
8787
),
88+
("SELECT s AS x FROM t", {"t": rows({"s": "struct{x:int}"}, [{"s": {"x": 1}}])}),
89+
("SELECT l AS x FROM t", {"t": rows({"l": "list[int]"}, [{"l": [1]}])}),
90+
(
91+
"SELECT named_struct('a', x, 'b', y) AS s FROM t",
92+
{"t": rows({"x": "int", "y": "int"}, [{"x": 1, "y": 2}])},
93+
),
94+
(
95+
"SELECT array(x, y) AS l FROM t",
96+
{"t": rows({"x": "int", "y": "int"}, [{"x": 1, "y": 2}])},
97+
),
8898
]
8999

90100

@@ -100,8 +110,6 @@ def test_committed_surface_is_never_deferred(query, tables):
100110

101111
_DEFERRED = [
102112
("SELECT unnest(l) AS x FROM t", {"t": rows({"l": "list[int]"}, [{"l": [1]}])}),
103-
("SELECT s AS x FROM t", {"t": rows({"s": "struct{x:int}"}, [{"s": {"x": 1}}])}),
104-
("SELECT l AS x FROM t", {"t": rows({"l": "list[int]"}, [{"l": [1]}])}),
105113
# A struct in a comparison must defer, not silently mis-evaluate: DataFusion
106114
# supports it, codegen does not yet, so it must raise (not return a wrong bool).
107115
(

tests/test_diff_errors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,13 @@ def test_int_mod_by_zero_rejected_by_both():
2929
"SELECT a % b AS r FROM t",
3030
{"t": rows({"a": "int", "b": "int"}, [{"a": 5, "b": 0}])},
3131
)
32+
33+
34+
def test_unknown_function_rejected_by_both():
35+
# foobar() is invalid SQL -- DataFusion errors -- and is NOT a `__tfm_`
36+
# transformer placeholder, so codegen must raise a hard error too (not
37+
# UnsupportedInCodegen, which the harness would skip as a deferred surface).
38+
check_both_raise(
39+
"SELECT foobar(x) AS y FROM t",
40+
{"t": rows({"x": "int"}, [{"x": 1}])},
41+
)

tests/test_diff_types.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,72 @@ def test_list_construct():
2121
)
2222

2323

24+
# The three construction shapes below (positional struct, named struct(...),
25+
# make_array) are supported on codegen and match the DataFusion oracle, but
26+
# native has no dispatch for them at all -- so they xfail_on_native, the same
27+
# parity-gap pattern as test_list_construct_mixed_numeric_widens. Codegen
28+
# correctness is the assertion; the native gaps want their own tickets.
29+
def test_struct_construct_positional(xfail_on_native):
30+
# struct(a, b) names fields positionally c0, c1 (matches DataFusion).
31+
xfail_on_native("native has no struct(...) construction dispatch (expr_build.rs)")
32+
check(
33+
"SELECT struct(a, b) AS s FROM t",
34+
{"t": rows({"a": "int", "b": "int"}, [{"a": 1, "b": 2}])},
35+
expect=[{"s": {"c0": 1, "c1": 2}}],
36+
)
37+
38+
39+
def test_struct_construct_named(xfail_on_native):
40+
# struct(a AS x, b AS y) parses as exp.PropertyEQ -> explicit field names.
41+
xfail_on_native("native has no struct(...) construction dispatch (expr_build.rs)")
42+
check(
43+
"SELECT struct(a AS x, b AS y) AS s FROM t",
44+
{"t": rows({"a": "int", "b": "int"}, [{"a": 1, "b": 2}])},
45+
expect=[{"s": {"x": 1, "y": 2}}],
46+
)
47+
48+
49+
def test_make_array_construct(xfail_on_native):
50+
# make_array(a, b) is a DataFusion builtin; native doesn't recognize it as a
51+
# function (only the bracket literal [a, b] reaches native's list path).
52+
xfail_on_native("native has no make_array function dispatch (expr_build.rs)")
53+
check(
54+
"SELECT make_array(a, b) AS l FROM t",
55+
{"t": rows({"a": "int", "b": "int"}, [{"a": 1, "b": 2}])},
56+
expect=[{"l": [1, 2]}],
57+
)
58+
59+
60+
def test_list_construct_mixed_numeric_widens(xfail_on_native):
61+
# DataFusion widens the int element to match the float element (list<double>).
62+
# Locks in infer_type's ListExpr arm unifying element bases via _common_base
63+
# (like COALESCE), so the output model types the list as list[float] and
64+
# pydantic coerces the runtime int element to float.
65+
#
66+
# Bracket literal `[x, y]` (not make_array(...)): native's function dispatch
67+
# doesn't recognize "make_array"/"array" as a builtin at all (a separate,
68+
# pre-existing gap -- expr_build.rs's convert_function has no such case,
69+
# unlike Python's ANONYMOUS-function branch), so it isn't a usable surface
70+
# to compare both engines against the oracle. The bracket form reaches the
71+
# same Expr::List / ListExpr construction path in both engines.
72+
#
73+
# xfail_on_native: measured -- native's unify_list_element_types
74+
# (src/types.rs) is exact-equality-only, the same bug class just fixed here
75+
# in codegen's infer_type. Native still emits [1, 2.5] (un-widened) instead
76+
# of DataFusion's [1.0, 2.5]. A native-side fix is out of scope here; flag
77+
# as a parity bug for its own ticket rather than fixing inline.
78+
xfail_on_native(
79+
"native does not widen mixed int/float list elements "
80+
"(types.rs unify_list_element_types is exact-equality-only) -- "
81+
"separate parity bug, own ticket"
82+
)
83+
check(
84+
"SELECT [x, y] AS l FROM t",
85+
{"t": rows({"x": "int", "y": "float"}, [{"x": 1, "y": 2.5}])},
86+
expect=[{"l": [1.0, 2.5]}],
87+
)
88+
89+
2490
def test_struct_input_roundtrip():
2591
check(
2692
"SELECT s FROM t",

tests/test_transformer_case.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@ def test_transformer_in_nonfirst_case_branch_parity_native():
5757

5858

5959
def test_transformer_in_case_defers_on_codegen():
60-
# Codegen has no transformer support and already defers named_struct; the
61-
# construct must defer loudly (UnsupportedInCodegen), never silently mishandle.
60+
# Codegen has no transformer support, so the __tfm_0__(...) call defers
61+
# (named_struct itself is supported now); the construct must defer loudly
62+
# (UnsupportedInCodegen), never silently mishandle.
6263
model = synthesize_this_model(pa.schema([("g", pa.int64()), ("age", pa.float64())]))
6364
with pytest.raises(UnsupportedInCodegen):
6465
CodegenFn(_SQL, row_tables={"__THIS__": model}, static_tables={})

0 commit comments

Comments
 (0)