|
| 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") |
0 commit comments