feat: introduce GenerateRel for lateral view and unnest operations#917
feat: introduce GenerateRel for lateral view and unnest operations#917EpsilonPrime wants to merge 11 commits intosubstrait-io:mainfrom
Conversation
7553b03 to
c1d33fc
Compare
benbellick
left a comment
There was a problem hiding this comment.
Very excited for this one, as it would be useful for modeling Unnest at my job.
Couple things that I think are high priority:
- Could we introduce the schema changes for table functions in simple_extension_schema.yaml?
- Could we introduce some proper examples into something like
functions_explode.yamlto show these definitions? This also helps ensure that downstream libraries are properly handling these things. - For my particular use case, I would love to be able to represent queries like
SELECT UNNEST(ARRAY[1,2]), UNNEST(ARRAY[4,5,6])which has a zipping behavior rather than cartesian product. What would be the best way to represent these? (One way I can think of is as variadic unnest call which has zipping behavior.)
All in all this will be great and I'm happy to help implement them in the downstream libraries.
| // standard SQL via LEFT JOIN LATERAL. | ||
| // | ||
| // Optional; defaults to false. | ||
| bool preserve_on_empty = 4; |
There was a problem hiding this comment.
An alternative would be to make this part of TableFunction but if there are other table expressions they might need this functionality.
There was a problem hiding this comment.
Based on my other comment on ApplyRel, this can be actually an enum of CROSS, OUTER, SEMI, and ANTISEMI, default to CROSS and stay in GenerateRel.
proto/substrait/algebra.proto
Outdated
| // For compatibility and optimization hints. | ||
| substrait.extensions.AdvancedExtension advanced_extension = 10; |
There was a problem hiding this comment.
| // For compatibility and optimization hints. | |
| substrait.extensions.AdvancedExtension advanced_extension = 10; | |
| substrait.extensions.AdvancedExtension advanced_extension = 10; |
None of the other usages of advanced_extension has a comment, so I think lets just drop it to be consistent.
Also, I understand wanting to set the field number to 10 for consistency, but there are actually usages of advanced_extension which are 10, 9, 5, and 4 in this file. Any reason now to just make it 5 so that it is the next field? TBH though this is not particularly important to me.
There was a problem hiding this comment.
Having them all the same makes it easier for some proto uses (but I can't name any at the moment). As part of Substrait 1.0 I'd want to see these moved up to 100 or higher. Lower field numbers compress better so it'd be nice to reserve some space (not that we really need 100 reserved field numbers).
proto/substrait/algebra.proto
Outdated
| // | ||
| // Required. | ||
| Type output_type = 4; | ||
| } |
There was a problem hiding this comment.
FYI this PR has a lot of overlap with this one I wrote before: #876
Though what you have here is more general than I what I did, and so I think your approach is better. I just wanted to share in case any of the discussion there is useful. One thing in particular that was discussed there: have you at all thought about variadic handling for table functions?
There was a problem hiding this comment.
The output needs to be determinable from the input. So for more complicated structures we likely need to upgrade the grammar. For now we can achieve most of what we need with function overloading.
westonpace
left a comment
There was a problem hiding this comment.
I have a general question. There are (at least) two ways in which table functions are utilized. I'm not entirely sure if these are two different concepts or the same concept.
Explode / lateral join / cross apply
I believe GenerateRel captures these cases. The input to the table function is the output of another relation. The arguments will (typically) include field references (or scalar functions applied to field references).
From
The other way in which table functions are applied is by using them directly in the FROM clause. For example SELECT * FROM myfunc(7). For example see postgres. In this case the arguments are (typically? always?) all literals.
Is the TableFunction message here intended to eventually be usable in both contexts? This PR itself only adds support for the first (which is fine). Still, it seems like message itself wouldn't have to change. I think the only change I can imagine is adding a new source rel that has no input rels and specifies the table function invocation.
7c02259 to
bbed1e8
Compare
Add GenerateRel, a new relational operator that applies table functions to
produce zero or more output rows per input row. This enables SQL LATERAL VIEW,
EXPLODE, and UNNEST operations for flattening nested data structures.
- Add TableFunction message (lines 1222-1262)
- New expression type for functions that produce multiple rows
- Fields: function_reference, arguments, options, output_type
- Output type must be a Struct defining generated columns
- Used exclusively in relational contexts (not in Expression.rex_type)
- Add GenerateRel message (lines 559-639)
- Applies table function to each input row
- Fields: common, input, generator, preserve_on_empty, advanced_extension
- Comprehensive inline documentation with examples
- Property maintenance: distribution and orderedness not maintained
- Output schema: [input_fields..., generated_fields...]
- Comparison to ExpandRel included in comments
- Add GenerateRel to Rel union (line 677)
- Field number 23 in Rel.rel_type oneof
- site/docs/relations/logical_relations.md
- Complete Generate Operation section with signature table
- Common table functions reference (explode, posexplode, unnest)
- Empty collection handling (preserve_on_empty flag)
- Detailed examples: array explode, posexplode, map explode
- Comparison table with ExpandRel
- site/docs/expressions/table_functions.md
- Comprehensive rewrite of table functions documentation
- TableFunction message field descriptions
- Differences from scalar/window/aggregate functions
- Usage examples with GenerateRel
- Extension mechanism explanation
1. **Dedicated TableFunction message**: Provides type safety and clear
semantics for multi-row producing functions, following the pattern of
ScalarFunction, WindowFunction, and AggregateFunction.
2. **preserve_on_empty flag**: Controls NULL handling when generators produce
zero rows. Named to reflect behavior (row preservation) rather than SQL
join terminology ("outer"). Equivalent to Spark's LATERAL VIEW OUTER or
standard SQL's LEFT JOIN LATERAL.
3. **No automatic ordinal column**: Unlike ExpandRel which appends an i32
ordinal, GenerateRel relies on table functions to include position
explicitly (e.g., posexplode includes pos field in output).
4. **TableFunction not in Expression**: Table functions produce multiple rows,
incompatible with Expression semantics (single value). Only used in
relational operators like GenerateRel.
- ExpandRel: Fixed cardinality (plan time), for grouping sets/cube
- GenerateRel: Variable cardinality (runtime), for lateral view/unnest
Based on Apache Gluten PR substrait-io#574 but with significant improvements:
- Well-documented fields (Gluten had minimal documentation)
- Dedicated TableFunction type (vs generic Expression)
- preserve_on_empty flag for outer semantics
- Comprehensive examples and property maintenance rules
None. This is a purely additive change using a new field number in Rel.
- Protobuf compilation verified with protoc
- Follows all Substrait conventions (common field 1, input field 2,
advanced_extension field 10)
- No trailing whitespace, proper line endings
773d5a7 to
b08bb10
Compare
b08bb10 to
afdef63
Compare
3892139 to
1d8b067
Compare
I believe it would work as long as you had a relation that worked just fine without requiring an input (which is currently the case here). I've expanded the implementation to create a TableExpression which gives us even more future capability but we will still have the same requirement -- we need to have a relation that allows us to provide no input. json_decode is one such function. Getting a little meta, I could also see a function (say substrait_execute) that takes a substrait plan as an argument using this. :) |
a79c08f to
b250ed0
Compare
|
@EpsilonPrime the link in your description to #574 is linking to an unrelated substrait issue. I think the intention is to link to apache/gluten#574. Is that correct? |
benbellick
left a comment
There was a problem hiding this comment.
I haven't done a complete review yet but just dumping my comments so far. Hopefully will get a chance to do rest later. Thanks!
| @@ -0,0 +1,109 @@ | |||
| %YAML 1.2 | |||
| --- | |||
| urn: extension:io.substrait:functions_table_generic | |||
There was a problem hiding this comment.
Very minor nit, but IMO adding _generic on the end doesn't really provide any extra information. Might as well just keep the URN shorter. Same goes with the file name. Feel free to ignore this one though
| urn: extension:io.substrait:functions_table_generic | |
| urn: extension:io.substrait:functions_table |
There was a problem hiding this comment.
This is mirroring the aggregate functions. I would go with table_functions over functions_table. One could functions is also devoid of information but table by itself fails to have enough information to make it clear what it is on its own.
| --- | ||
| urn: extension:io.substrait:functions_table_generic | ||
| table_functions: | ||
| - name: explode |
There was a problem hiding this comment.
You have two explodes in the yaml file here. I don't believe that this is technically incorrect, though I opened up an issue (#931) proposing making it the case that function names must be unique.
Is it possible / sensible to make this one function with two impls? And then just have the description describe both? I think this is simpler to handle for both implementers and easier for humans to read.
| - name: input4 | ||
| value: list<T4> | ||
| description: Fourth array to unnest. | ||
| return: struct<T1, T2, T3, T4> |
There was a problem hiding this comment.
This was kind of the exact difficulty I was dealing with in my version of the unnest PR. I think this is a fine solution for now, but it will be great in the future to figure out a more flexible grammar for describing this behavior for an arbitrarily large number of inputs.
| } | ||
| } | ||
|
|
||
| // TableExpression represents expressions that produce zero or more rows, |
There was a problem hiding this comment.
If TableExpression only makes sense in the context of GenerateRel, should we move this proto definition to be within the scope of GenerateRel?
There was a problem hiding this comment.
Table expressions are definitely possible elsewhere. I could see read relations being redesigned using table expressions.
| // TableExpression expressions cannot be composed with other expressions. | ||
| message TableExpression { | ||
| oneof table_expr_type { | ||
| TableFunction table_function = 1; |
There was a problem hiding this comment.
What are the other things here that you anticipate adding in the future?
There was a problem hiding this comment.
It's possible that table expressions could have their own operators (perhaps implementing joins, projects, or fetches). I'm using Expression as a guide here.
There was a problem hiding this comment.
I plan to send a proposal to add ApplyRel by next week, which will behave like a join at high-level but taking a RelOp tree on the subquery part. We could consolidate that with this GeneratorRel with following extensions to GenerateRel.
- TableExpression should have
RelOp subqueryin its oneof. subqueryshould reference the input field by scoped field reference (outer reference).- There is join type equivalent options (cross, outer, semi, and anti-semi). These will determine the behavior of the apply as well as output schema.
@EpsilonPrime do you see we need a seperate ApplyRel or extend GeneratorRel?
There was a problem hiding this comment.
One needs preserve on empty, the other RelOp. That feels different enough to me. Fortunately there's enough room for ApplyRel in TableExpression.
|
|
||
| Some systems call this unnest. |
There was a problem hiding this comment.
This also applies to the other explode
|
|
||
| For each input row, this function generates one output row for each element | ||
| in the input array. The output is a struct with two fields: | ||
| - 'pos': 0-indexed position of the element in the array (i64) |
There was a problem hiding this comment.
silly thing. some systems do use 1-indexed. do we want to have it as a function option?
There was a problem hiding this comment.
Could conversion from 0-indexed to 1-indexed is something that could be handled by their Substrait layer? If so, then we always canonicalize one way.
| impls: | ||
| - args: | ||
| - name: input | ||
| value: list<any1> | ||
| description: The array to unnest. | ||
| return: struct<any1> | ||
| - args: | ||
| - name: input1 | ||
| value: list<any1> | ||
| description: First array to unnest. | ||
| - name: input2 | ||
| value: list<any2> | ||
| description: Second array to unnest. | ||
| return: struct<any1, any2> | ||
| - args: | ||
| - name: input1 | ||
| value: list<any1> | ||
| description: First array to unnest. | ||
| - name: input2 | ||
| value: list<any2> | ||
| description: Second array to unnest. | ||
| - name: input3 | ||
| value: list<any3> | ||
| description: Third array to unnest. | ||
| return: struct<any1, any2, any3> | ||
| - args: | ||
| - name: input1 | ||
| value: list<any1> | ||
| description: First array to unnest. | ||
| - name: input2 | ||
| value: list<any2> | ||
| description: Second array to unnest. | ||
| - name: input3 | ||
| value: list<any3> | ||
| description: Third array to unnest. | ||
| - name: input4 | ||
| value: list<any4> | ||
| description: Fourth array to unnest. | ||
| return: struct<any1, any2, any3, any4> |
There was a problem hiding this comment.
can this be expressed using inconsistent variadic arguments? or we need to extend the type grammar?
There was a problem hiding this comment.
We need to extend the type grammar. I left that to a future project.
| // This is equivalent to Spark's "LATERAL VIEW OUTER" or can be achieved in | ||
| // standard SQL via LEFT JOIN LATERAL. | ||
| // | ||
| // Optional; defaults to false. |
| // TableExpression expressions cannot be composed with other expressions. | ||
| message TableExpression { | ||
| oneof table_expr_type { | ||
| TableFunction table_function = 1; |
There was a problem hiding this comment.
I plan to send a proposal to add ApplyRel by next week, which will behave like a join at high-level but taking a RelOp tree on the subquery part. We could consolidate that with this GeneratorRel with following extensions to GenerateRel.
- TableExpression should have
RelOp subqueryin its oneof. subqueryshould reference the input field by scoped field reference (outer reference).- There is join type equivalent options (cross, outer, semi, and anti-semi). These will determine the behavior of the apply as well as output schema.
@EpsilonPrime do you see we need a seperate ApplyRel or extend GeneratorRel?
| - `explode(array<i32>)` → `Struct{value: i32}` | ||
| - `posexplode(array<string>)` → `Struct{pos: i64, value: string}` | ||
| - `explode(map<string, i32>)` → `Struct{key: string, value: i32}` |
There was a problem hiding this comment.
wait.. do we have named struct? 😄
There was a problem hiding this comment.
My understanding is that they are actually just STRUCTS always, but you can use them when it is convenient to pretend something has names 🤷
| | **Input scope** | Single row | Window of rows | Group of rows | Single row | | ||
| | **Output cardinality** | Exactly 1 value | Exactly 1 value | Exactly 1 value | 0 or more rows | | ||
| | **Output type** | Scalar type | Scalar type | Scalar type | Struct type | | ||
| | **Can appear in Expression** | Yes | Yes (WindowFunction) | No | No | |
There was a problem hiding this comment.
it could appear in the make_array or make_struct like functions if we have.. no?
| |----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| | ||
| | Inputs | 1 | | ||
| | Outputs | 1 | | ||
| | Property Maintenance | Distribution and orderedness are not maintained. The variable number of output rows per input breaks distribution properties, and row multiplication breaks ordering. | |
There was a problem hiding this comment.
input distribution is preserved as long as the input fields are intact and available -- which should be the case by definition.
| | Inputs | 1 | | ||
| | Outputs | 1 | | ||
| | Property Maintenance | Distribution and orderedness are not maintained. The variable number of output rows per input breaks distribution properties, and row multiplication breaks ordering. | | ||
| | Direct Output Order | Input fields followed by generated fields. The output schema is: `[input_field_1, ..., input_field_N, generated_field_1, ..., generated_field_M]` where generated fields come from the table function's output type. Unlike ExpandRel which appends an i32 ordinal column, GenerateRel does NOT automatically add an ordinal column. Table functions that need position information (like posexplode) include it explicitly in their output_type. The RelCommon.emit field can be used to reorder columns or project out unwanted fields. | |
There was a problem hiding this comment.
isn't output type is STRUCT? So we automatically flatten the struct in direct output order?
There was a problem hiding this comment.
Returning a struct is possible but it will be harder to use. We have columns we might as well use them.
| | Outputs | 1 | | ||
| | Property Maintenance | Distribution and orderedness are not maintained. The variable number of output rows per input breaks distribution properties, and row multiplication breaks ordering. | | ||
| | Direct Output Order | Input fields followed by generated fields. The output schema is: `[input_field_1, ..., input_field_N, generated_field_1, ..., generated_field_M]` where generated fields come from the table function's output type. Unlike ExpandRel which appends an i32 ordinal column, GenerateRel does NOT automatically add an ordinal column. Table functions that need position information (like posexplode) include it explicitly in their output_type. The RelCommon.emit field can be used to reorder columns or project out unwanted fields. | | ||
|
|
There was a problem hiding this comment.
can the position information be part of optional behavior of generator rel? In that way, we don't need extra function and implementation can opt to pick which mode it wants... something like
enum OffsetField
{
NONE, // not generating offset value (default)
ZERO_BASED, // 0-based index
ONE_BASED // 1-based index
}when the option is there, the last field is the offset.
Add GenerateRel, a new relational operator that applies table functions to produce zero or more output rows per input row. This enables SQL LATERAL VIEW, EXPLODE, and UNNEST operations for flattening nested data structures.
Changes
Key Design Decisions
Dedicated TableFunction message: Provides type safety and clear semantics for multi-row producing functions, following the pattern of ScalarFunction, WindowFunction, and AggregateFunction.
preserve_on_empty flag: Controls NULL handling when generators produce zero rows. Named to reflect behavior (row preservation) rather than SQL join terminology ("outer"). Equivalent to Spark's LATERAL VIEW OUTER or standard SQL's LEFT JOIN LATERAL.
No automatic ordinal column: Unlike ExpandRel which appends an i32 ordinal, GenerateRel relies on table functions to include position explicitly (e.g., posexplode includes pos field in output) when needed.
TableFunction not in Expression: Table functions produce multiple rows, incompatible with Expression semantics (single value). Only used in relational operators like GenerateRel.
Comparison to ExpandRel
Related Work
Will be used to unfork the changes made in Apache Gluten PR #574.
This change is