Skip to content

Commit 4b2129e

Browse files
feat(bigframes): Add substrait-datafusion engine
1 parent 65f059e commit 4b2129e

6 files changed

Lines changed: 5224 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
from .compiler import SubstraitCompiler
18+
19+
__all__ = ["SubstraitCompiler"]
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import json
18+
from typing import Any, Dict, Optional
19+
20+
import substrait.algebra_pb2 as algebra_pb2
21+
import substrait.plan_pb2 as plan_pb2
22+
from google.protobuf import json_format
23+
24+
from bigframes.core import bigframe_node, nodes
25+
import bigframes.core.expression as ex
26+
import pandas as pd
27+
28+
29+
class SubstraitCompiler:
30+
"""
31+
Compiles BigFrameNode plans to Substrait schema (JSON representation).
32+
"""
33+
34+
def compile(self, plan: bigframe_node.BigFrameNode) -> Optional[bytes]:
35+
"""
36+
Compiles a BigFrameNode to Substrait bytes (JSON encoded via protobuf).
37+
"""
38+
if not self.can_compile(plan):
39+
return None
40+
41+
plan_dict = self._compile_node(plan)
42+
43+
pb_plan = plan_pb2.Plan()
44+
pb_plan.version.minor_number = 42
45+
46+
plan_rel = pb_plan.relations.add()
47+
json_format.ParseDict(plan_dict, plan_rel.root.input)
48+
49+
plan_rel.root.names.extend([item.column for item in plan.schema.items])
50+
51+
extensions = [
52+
("add", 1),
53+
("sub", 2),
54+
("mul", 3),
55+
("div", 4),
56+
("eq", 5),
57+
("ne", 6),
58+
("lt", 7),
59+
("gt", 8),
60+
("le", 9),
61+
("ge", 10),
62+
]
63+
for name, anchor in extensions:
64+
ext = pb_plan.extensions.add()
65+
ext.extension_function.function_anchor = anchor
66+
ext.extension_function.name = name
67+
68+
return json_format.MessageToJson(pb_plan).encode('utf-8')
69+
70+
def can_compile(self, plan: bigframe_node.BigFrameNode) -> bool:
71+
"""
72+
Checks if the plan can be compiled to Substrait.
73+
For the skeleton, we support ReadLocalNode, SelectionNode, and FilterNode.
74+
"""
75+
supported_nodes = (
76+
nodes.ReadLocalNode,
77+
nodes.SelectionNode,
78+
nodes.FilterNode,
79+
nodes.SliceNode,
80+
nodes.ProjectionNode,
81+
nodes.JoinNode,
82+
nodes.AggregateNode,
83+
)
84+
return all(isinstance(node, supported_nodes) for node in plan.unique_nodes())
85+
86+
def _compile_node(self, node: bigframe_node.BigFrameNode) -> Dict[str, Any]:
87+
if isinstance(node, nodes.ReadLocalNode):
88+
return self._compile_read(node)
89+
elif isinstance(node, nodes.SelectionNode):
90+
return self._compile_selection(node)
91+
elif isinstance(node, nodes.FilterNode):
92+
return self._compile_filter(node)
93+
elif isinstance(node, nodes.SliceNode):
94+
return self._compile_slice(node)
95+
elif isinstance(node, nodes.ProjectionNode):
96+
return self._compile_projection(node)
97+
elif isinstance(node, nodes.JoinNode):
98+
return self._compile_join(node)
99+
elif isinstance(node, nodes.AggregateNode):
100+
return self._compile_aggregate(node)
101+
else:
102+
raise NotImplementedError(f"Node type {type(node)} not supported in Substrait compiler yet")
103+
104+
def _compile_read(self, node: nodes.ReadLocalNode) -> Dict[str, Any]:
105+
table_name = f"table_{node.local_data_source.id.hex}"
106+
107+
rel = algebra_pb2.Rel()
108+
read_rel = rel.read
109+
read_rel.named_table.names.append(table_name)
110+
111+
schema_dict = self._convert_schema(node.local_data_source.schema)
112+
json_format.ParseDict(schema_dict, read_rel.base_schema)
113+
114+
return json_format.MessageToDict(rel, preserving_proto_field_name=True)
115+
116+
def _compile_selection(self, node: nodes.SelectionNode) -> Dict[str, Any]:
117+
# Selection usually maps to ProjectRel or FilterRel depending on if it filters or just selects columns.
118+
# If it's just column selection (Projection), it's a ProjectRel.
119+
# Let's assume it's a ProjectRel for now.
120+
input_rel = self._compile_node(node.child)
121+
return {
122+
"project": {
123+
"input": input_rel,
124+
"expressions": [
125+
# Skeletal expression mapping
126+
{"selection": {"direct_reference": {"struct_field": {"field": i}}}}
127+
for i in range(len(node.schema))
128+
]
129+
}
130+
}
131+
132+
def _compile_filter(self, node: nodes.FilterNode) -> Dict[str, Any]:
133+
input_rel = self._compile_node(node.child)
134+
condition_rel = self._compile_expression(node.condition, node.child)
135+
return {
136+
"filter": {
137+
"input": input_rel,
138+
"condition": condition_rel
139+
}
140+
}
141+
142+
def _compile_slice(self, node: nodes.SliceNode) -> Dict[str, Any]:
143+
input_rel = self._compile_node(node.child)
144+
count = node.stop if node.stop is not None else -1
145+
offset = node.start if node.start is not None else 0
146+
147+
return {
148+
"fetch": {
149+
"input": input_rel,
150+
"offset": offset,
151+
"count": count
152+
}
153+
}
154+
155+
def _compile_projection(self, node: nodes.ProjectionNode) -> Dict[str, Any]:
156+
input_rel_dict = self._compile_node(node.child)
157+
158+
rel = algebra_pb2.Rel()
159+
project_rel = rel.project
160+
161+
json_format.ParseDict(input_rel_dict, project_rel.input)
162+
163+
# DataFusion ProjectRel seems to be additive (appends to input).
164+
# So we don't need to add passthrough expressions for input fields.
165+
166+
# Add new assignments
167+
for expr, _ in node.assignments:
168+
expr_dict = self._compile_expression(expr, node.child)
169+
expr_pb = project_rel.expressions.add()
170+
json_format.ParseDict(expr_dict, expr_pb)
171+
172+
return json_format.MessageToDict(rel, preserving_proto_field_name=True)
173+
174+
def _compile_join(self, node: nodes.JoinNode) -> Dict[str, Any]:
175+
left_rel = self._compile_node(node.left_child)
176+
right_rel = self._compile_node(node.right_child)
177+
178+
type_map = {
179+
"inner": "JOIN_TYPE_INNER",
180+
"left": "JOIN_TYPE_LEFT",
181+
"right": "JOIN_TYPE_RIGHT",
182+
"outer": "JOIN_TYPE_OUTER",
183+
"cross": "JOIN_TYPE_CROSS",
184+
}
185+
join_type = type_map.get(node.type, "JOIN_TYPE_UNSPECIFIED")
186+
187+
left_len = len(node.left_child.schema)
188+
189+
eq_expressions = []
190+
for left_deref, right_deref in node.conditions:
191+
left_idx = list(node.left_child.ids).index(left_deref.id)
192+
right_idx = list(node.right_child.ids).index(right_deref.id) + left_len
193+
194+
eq_expressions.append({
195+
"scalar_function": {
196+
"function_reference": 0,
197+
"arguments": [
198+
{"value": {"selection": {"direct_reference": {"struct_field": {"field": left_idx}}}}},
199+
{"value": {"selection": {"direct_reference": {"struct_field": {"field": right_idx}}}}}
200+
]
201+
}
202+
})
203+
204+
if len(eq_expressions) > 1:
205+
expr = eq_expressions[0]
206+
elif len(eq_expressions) == 1:
207+
expr = eq_expressions[0]
208+
else:
209+
expr = {"literal": {"boolean": True}}
210+
211+
return {
212+
"join": {
213+
"left": left_rel,
214+
"right": right_rel,
215+
"expression": expr,
216+
"type": join_type
217+
}
218+
}
219+
220+
def _compile_aggregate(self, node: nodes.AggregateNode) -> Dict[str, Any]:
221+
input_rel = self._compile_node(node.child)
222+
223+
groupings = []
224+
grouping_expressions = []
225+
for deref in node.by_column_ids:
226+
idx = list(node.child.ids).index(deref.id)
227+
grouping_expressions.append({"selection": {"direct_reference": {"struct_field": {"field": idx}}}})
228+
if grouping_expressions:
229+
groupings.append({"grouping_expressions": grouping_expressions})
230+
231+
measures = []
232+
for agg, _ in node.aggregations:
233+
func_ref = 1 if "Sum" in type(agg).__name__ else 2
234+
args = []
235+
if hasattr(agg, "column_references"):
236+
for col_id in agg.column_references:
237+
try:
238+
idx = list(node.child.ids).index(col_id)
239+
args.append({"value": {"selection": {"direct_reference": {"struct_field": {"field": idx}}}}})
240+
except ValueError:
241+
pass
242+
measures.append({
243+
"measure": {
244+
"function_reference": func_ref,
245+
"arguments": args
246+
}
247+
})
248+
249+
return {
250+
"aggregate": {
251+
"input": input_rel,
252+
"groupings": groupings,
253+
"measures": measures
254+
}
255+
}
256+
257+
def _compile_expression(self, expr: ex.Expression, child: nodes.BigFrameNode) -> Dict[str, Any]:
258+
if isinstance(expr, ex.ScalarConstantExpression):
259+
val = expr.value
260+
if isinstance(val, int):
261+
return {"literal": {"i64": val}}
262+
elif isinstance(val, float):
263+
return {"literal": {"fp64": val}}
264+
elif isinstance(val, str):
265+
return {"literal": {"string": val}}
266+
elif pd.isna(val):
267+
return {"literal": {"null": {"varchar": {"length": 0}}}}
268+
else:
269+
return {"literal": {"string": str(val)}}
270+
271+
elif isinstance(expr, ex.DerefOp):
272+
try:
273+
# print(f"DerefOp: id={expr.id}, child.ids={list(child.ids)}") # Debug
274+
idx = list(child.ids).index(expr.id)
275+
return {"selection": {"direct_reference": {"struct_field": {"field": idx}}}}
276+
except ValueError:
277+
raise ValueError(f"Column {expr.id} not found in child schema")
278+
279+
elif isinstance(expr, ex.OpExpression):
280+
op_name = expr.op.name
281+
op_mapping = {
282+
"add": 1,
283+
"sub": 2,
284+
"mul": 3,
285+
"div": 4,
286+
"eq": 5,
287+
"ne": 6,
288+
"lt": 7,
289+
"gt": 8,
290+
"le": 9,
291+
"ge": 10,
292+
}
293+
if op_name not in op_mapping:
294+
raise NotImplementedError(f"Operation {op_name} not supported in Substrait compiler yet")
295+
func_ref = op_mapping[op_name]
296+
297+
args = [self._compile_expression(arg, child) for arg in expr.inputs]
298+
return {
299+
"scalar_function": {
300+
"function_reference": func_ref,
301+
"arguments": [{"value": arg} for arg in args]
302+
}
303+
}
304+
else:
305+
raise NotImplementedError(f"Expression type {type(expr)} not supported in Substrait compiler yet")
306+
307+
def _convert_schema(self, schema: Any) -> Dict[str, Any]:
308+
# Convert bigframes schema to Substrait Type.NamedStruct
309+
fields = []
310+
types = []
311+
for item in schema.items:
312+
col = item.column
313+
name = col.name if hasattr(col, "name") else str(col)
314+
fields.append(name)
315+
types.append(self._convert_type(item.dtype))
316+
317+
return {
318+
"names": fields,
319+
"struct": {"types": types}
320+
}
321+
322+
def _convert_type(self, dtype: Any) -> Dict[str, Any]:
323+
import bigframes.dtypes
324+
if dtype == bigframes.dtypes.INT_DTYPE:
325+
return {"i64": {}}
326+
elif dtype == bigframes.dtypes.FLOAT_DTYPE:
327+
return {"fp64": {}}
328+
elif dtype == bigframes.dtypes.BOOL_DTYPE:
329+
return {"bool": {}}
330+
elif dtype == bigframes.dtypes.STRING_DTYPE:
331+
return {"string": {}}
332+
else:
333+
# Fallback to string for now
334+
return {"string": {}}

0 commit comments

Comments
 (0)