Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions extensions/functions_table_generic.yaml
Comment thread
EpsilonPrime marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
%YAML 1.2
---
urn: extension:io.substrait:functions_table_generic

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
urn: extension:io.substrait:functions_table_generic
urn: extension:io.substrait:functions_table

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

table_functions:
- name: explode

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

description: >-
Produces one row per array element.

For each input row, this function generates one output row for each element
in the input array. The output is a struct with a single field 'value'
containing the array element.

Empty arrays produce zero output rows. For outer semantics (producing a row
with NULL when the array is empty), use GenerateRel's preserve_on_empty option.
impls:
- args:
- name: input
value: list<any1>
description: The array to explode.
return: struct<any1>

- name: explode
description: >-
Produces one row per map entry.

For each input row, this function generates one output row for each key-value
pair in the input map. The output is a struct with two fields: 'key' and 'value'.
Note that map entries may be visited in any order.

Empty maps produce zero output rows. For outer semantics (producing a row
with NULLs when the map is empty), use GenerateRel's preserve_on_empty option.

Some systems call this unnest.
Comment on lines +32 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also applies to the other explode

impls:
- args:
- name: input
value: map<any1, any2>
description: The map to explode.
return: struct<any1, any2>

- name: posexplode
description: >-
Produces one row per array element with position.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

silly thing. some systems do use 1-indexed. do we want to have it as a function option?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

- 'value': the array element

Empty arrays produce zero output rows. For outer semantics (producing a row
with NULLs when the array is empty), use GenerateRel's preserve_on_empty option.
impls:
- args:
- name: input
value: list<any1>
description: The array to explode with positions.
return: struct<i64, any1>

- name: unnest
description: >-
Expands one or more lists into rows by zipping them in parallel (PostgreSQL semantics).

When multiple lists are provided, produces rows by expanding the lists in parallel,
similar to a zip operation. For example, unnest([1,2], [3,4]) produces rows: (1,3), (2,4).

If lists have different lengths, shorter lists are padded with NULL values to match
the length of the longest list.

Empty lists (when all inputs are empty) produce zero output rows. For outer semantics
(producing a row with NULLs when all lists are empty), use GenerateRel's preserve_on_empty option.
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>
Comment on lines +71 to +109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be expressed using inconsistent variadic arguments? or we need to extend the type grammar?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to extend the type grammar. I left that to a future project.

1 change: 1 addition & 0 deletions grammar/FuncTestCaseLexer.g4
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Whitespace : [ \t\n\r]+ -> channel(HIDDEN) ;
TripleHash: '###';
SubstraitScalarTest: 'SUBSTRAIT_SCALAR_TEST';
SubstraitAggregateTest: 'SUBSTRAIT_AGGREGATE_TEST';
SubstraitTableTest: 'SUBSTRAIT_TABLE_TEST';
SubstraitInclude: 'SUBSTRAIT_INCLUDE';
SubstraitDependency: 'SUBSTRAIT_DEPENDENCY';

Expand Down
59 changes: 50 additions & 9 deletions grammar/FuncTestCaseParser.g4
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,33 @@ options {
}

doc
: header testGroup+ EOF
: scalarDoc
| aggregateDoc
| tableDoc
;

header
: version include dependency*
scalarDoc
: scalarHeader scalarTestGroup+ EOF
;

version
: TripleHash (SubstraitScalarTest | SubstraitAggregateTest) Colon FormatVersion
aggregateDoc
: aggregateHeader aggregateTestGroup+ EOF
;

tableDoc
: tableHeader tableTestGroup+ EOF
;

scalarHeader
: TripleHash SubstraitScalarTest Colon FormatVersion include dependency*
;

aggregateHeader
: TripleHash SubstraitAggregateTest Colon FormatVersion include dependency*
;

tableHeader
: TripleHash SubstraitTableTest Colon FormatVersion include dependency*
;

include
Expand All @@ -34,9 +52,16 @@ testCase
: functionName=identifier OParen arguments CParen ( OBracket funcOptions CBracket )? Eq result
;

testGroup
: testGroupDescription? (testCase)+ #scalarFuncTestGroup
| testGroupDescription? (aggFuncTestCase)+ #aggregateFuncTestGroup
scalarTestGroup
: testGroupDescription? testCase+
;

aggregateTestGroup
: testGroupDescription? aggFuncTestCase+
;

tableTestGroup
: testGroupDescription? tableFuncTestCase+
;

arguments
Expand Down Expand Up @@ -76,6 +101,18 @@ aggFuncTestCase
: aggFuncCall ( OBracket funcOptions CBracket )? Eq result
;

tableFuncTestCase
: functionName=identifier OParen arguments CParen ( OBracket funcOptions CBracket )? Eq multiRowResult
;

multiRowResult
: OBracket (rowTuple (Comma rowTuple)*)? CBracket DoubleColon structType
;

rowTuple
: OParen (literal (Comma literal)*)? CParen
;

aggFuncCall
: tableData funcName=identifier OParen qualifiedAggregateFuncArgs? CParen #multiArgAggregateFuncCall
| tableRows functName=identifier OParen aggregateFuncArgs? CParen #compactAggregateFuncCall
Expand Down Expand Up @@ -344,12 +381,16 @@ parameterizedType
| precisionTimestampType
| precisionTimestampTZType
| funcType
| structType
// TODO implement the rest of the parameterized types
// | Struct isnull='?'? Lt expr (Comma expr)* Gt #struct
// | NStruct isnull='?'? Lt Identifier expr (Comma Identifier expr)* Gt #nStruct
// | Map isnull='?'? Lt key=expr Comma value=expr Gt #map
;

structType
: Struct isnull=QMark? OAngleBracket dataType (Comma dataType)* CAngleBracket
;

numericParameter
: IntegerLiteral #integerLiteral
;
Expand Down
84 changes: 84 additions & 0 deletions proto/substrait/algebra.proto
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,41 @@ message ExpandRel {
}
}

// The Generate operator applies a table function to produce zero or more output
// rows for each input row. This operation is used to flatten or "explode" nested
// data structures like arrays and maps into separate rows, similar to SQL's
// LATERAL VIEW EXPLODE or UNNEST operations.
message GenerateRel {
RelCommon common = 1;

// The input relation to generate from. Each input row will produce zero or
// more output rows based on the generator table function.
Rel input = 2;

// The table expression to apply to each input row. This produces zero
// or more rows per input row. The output_type defines the schema
// of the generated columns, which are appended to the input schema.
//
// Required.
TableExpression generator = 3;

// Controls behavior when the generator produces zero rows for an input record.
//
// When false (default):
// Input rows that produce zero output rows are excluded from the result.
//
// When true:
// Input rows that produce zero output rows still generate one output row
// with the input fields populated and the generator output fields set to NULL.
// This is equivalent to Spark's "LATERAL VIEW OUTER" or can be achieved in
// standard SQL via LEFT JOIN LATERAL.
//
// Optional; defaults to false.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not: Optional:?

bool preserve_on_empty = 4;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a similar idea to this PR here from @yongchul: #890

I wonder if there is some way to unify these ideas?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative would be to make this part of TableFunction but if there are other table expressions they might need this functionality.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.


substrait.extensions.AdvancedExtension advanced_extension = 10;
}

// A relation with output field names.
//
// This is for use at the root of a `Rel` tree.
Expand Down Expand Up @@ -585,6 +620,7 @@ message Rel {
WriteRel write = 19;
DdlRel ddl = 20;
UpdateRel update = 22;
GenerateRel generate = 23;
// Physical relations
HashJoinRel hash_join = 13;
MergeJoinRel merge_join = 14;
Expand Down Expand Up @@ -1690,6 +1726,54 @@ message Expression {
}
}

// TableExpression represents expressions that produce zero or more rows,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If TableExpression only makes sense in the context of GenerateRel, should we move this proto definition to be within the scope of GenerateRel?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Table expressions are definitely possible elsewhere. I could see read relations being redesigned using table expressions.

// as opposed to Expression which produces a single scalar value.
// Table expressions are used in relational contexts where multi-row output
// is expected (e.g., GenerateRel for lateral views and unnest operations).
//
// Since they have different cardinality than Expression expressions,
// TableExpression expressions cannot be composed with other expressions.
message TableExpression {
oneof table_expr_type {
TableFunction table_function = 1;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the other things here that you anticipate adding in the future?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 subquery in its oneof.
  • subquery should 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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One needs preserve on empty, the other RelOp. That feels different enough to me. Fortunately there's enough room for ApplyRel in TableExpression.

}

// A table function call that produces zero or more rows per invocation.
// Table functions are similar to scalar functions but produce multiple output rows
// instead of a single scalar value. They are commonly used with the GenerateRel
// operator to flatten nested data structures like arrays and maps.
message TableFunction {
// Points to a function_anchor defined in this plan, which must refer
// to a table function in the associated YAML file. Table functions are
// distinguished from scalar functions by their ability to produce multiple
// output rows. Required; 0 is a valid anchor/reference.
uint32 function_reference = 1;

// The arguments to be bound to the table function. This must have exactly the
// number of arguments specified in the function definition, and the argument
// types must match:
//
// - Value arguments must be bound using FunctionArgument.value
// - Type arguments must be bound using FunctionArgument.type
// - Enum arguments must be bound using FunctionArgument.enum
//
// For table functions, arguments typically reference fields from the input
// relation that contain nested data (arrays, maps, etc.) to be exploded.
repeated FunctionArgument arguments = 2;

// Options to specify behavior for corner cases (e.g., handling malformed data,
// ordering within generated rows, deduplication).
repeated FunctionOption options = 3;

// Must be set to the output type of the table function. For table functions,
// this must be a Struct type where each field represents a column in the
// generated output rows.
//
// Required.
Type output_type = 4;
}
}

// Expression that represents a dynamic parameter.
// Dynamic parameters are identified by a surrogate key within a plan.
message DynamicParameter {
Expand Down
Loading