Skip to content
Closed
92 changes: 92 additions & 0 deletions extensions/functions_table.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
%YAML 1.2
---
# Table functions: Functions that produce relations (zero or more records).
# Currently, only 0-input functions are supported - these take constant arguments
# and generate data as leaf operators.
urn: extension:io.substrait:functions_table
table_functions:
- name: "generate_series"
description: >-
Generates a series of integer values from start to stop, incrementing by step.

Takes constant arguments and produces zero or more records containing a single
integer value. The series includes both the start and stop values if they fall
on a step boundary. If step is positive, stops when the value exceeds stop.

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.

Do we want to say something about the default value of step?

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.

Would it be better to instead drop the implementations where step is missing? That way, we force the plans to be more explicit.

If step is negative, stops when the value is less than stop. Returns empty if
step is zero or if the step direction doesn't allow reaching stop from start.
impls:
- args:
- name: start
value: i64
description: The starting value of the series
- name: stop
value: i64
description: The ending value of the series (inclusive)

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.

Looks like there is an interesting spilit between stop being inclusive and exclusive.

  • python, pandas and snowflake exclude the stop value.
  • PostgreSQL, T-SQL, ZetaSQL include the stop value.

I guess either way is fine... Another problem is that some systems do support generating series for other numeric types (e.g., fp32, fp64, decimal) but we can add those later.

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 could add this as a function option which specifies if they include or exclude the stop value. What do others think?

- name: step
value: i64
description: The increment between values
constant: true

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.

Is this constant defined somewhere? What does it mean?

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.

https://substrait.io/expressions/scalar_functions/#argument-types

Value arguments: arguments that refer to a data value. These could be constants (literal expressions defined in the plan) or variables (a reference expression that references data being processed by the plan).

My understanding is that constant means that a value can only be a literal and not a reference to a value unknown at plan construction e.g. a column reference. Although I'm unsure why we wouldn't allow these values to be expressions which lack references.

All that being said, postgresql does in fact accept column references in the arguments to generate_series (example here), so I think it makes sense to lift this restriction.

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.

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.

We shouldn't unnecessarily restrict ourselves to literals and this is another place we shouldn't IMO. Implementation may not support generic expression in certain places but we shouldn't prevent it from the spec. In fact, this is another potential information to encode in dialect whether an expression has some restrictions.

return:
names:
- value
struct:
types:
- i64
- args:
- name: start
value: i32
description: The starting value of the series
- name: stop
value: i32
description: The ending value of the series (inclusive)
- name: step
value: i32
description: The increment between values
constant: true
return:
names:
- value
struct:
types:
- i32
- args:
- name: start
value: i64
description: The starting value of the series
- name: stop
value: i64
description: The ending value of the series (inclusive)
return:
names:
- value
struct:
types:
- i64
- args:
- name: start
value: i32
description: The starting value of the series
- name: stop
value: i32
description: The ending value of the series (inclusive)
return:
names:
- value
struct:
types:
- i32
- name: "unnest"
description: Expands a list expression into a set of rows, one row per element.
impls:
- args:
- name: input
value: list<T>
description: The list to unnest
# Schema references type parameter T from list<T>
# The field type is derived from the list element type
return:

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.

I have made this a draft PR again. I need to have a proper think about how we can represent this dynamic return type if we really want to have these functions be variadic 😰

names:
- element
struct:
types:
- T
56 changes: 56 additions & 0 deletions proto/substrait/algebra.proto
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,61 @@ message ExpandRel {
}
}

// Invokes a table-valued function that produces a relation (zero or more records).
//
//
// Table functions produce a table with a schema that can be:
// - Statically defined (concrete types in YAML)
// - Type-parameterized (derived from argument types, e.g., unnest(list<T>) -> {element: T})
// - Runtime-dependent (determined by inspecting data content, use derived: false)
//
// Future extensions may add an optional input field to support transformation
// table functions that operate on input relations.
message TableFunctionRel {

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.

Is a more appropriate name for this GeneratorTableFunctionRel as introduced by
@jacques-n here?

In my mind there are two possible paths we can go down which determine the appropriate name.

  1. We could explore expanding this proto (by e.g. adding a relations input field) so that it appropriately models both kinds of table functions. In this case, keeping the name as TableFunctionRel makes sense.
  2. We could introduce a brand new relation to represent table functions which take relations as input. In this case, it would make sense to rename this relation to GeneratorTableRel or some name which distinguishes it from the other kind of table function.

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.

From my biased view, it is better to categorize them... roughly

  • Scan like (0 relational input)
  • Project or Filter like (1 relational input)
  • (N-ary) Join like (2+ relational inputs optional grouping support)
  • Group like (1 relational input with grouping support)

This gives way more modular and systematic way to describe a table function.

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.

Is your suggestion to have a different relation for each one of those? Or one shared relation? Maybe something like the following would be preferable...

message TableFunctionRel {
  ...
  oneof {
    ScanLike scan_like = 1; // PR implements this one
    //ProjectLike project_like = 2;
    //JoinLike join_like = 3;
    //GroupLike group_like = 4;
  }
  // implementations below
  ...
}

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 prefer Rel for each type, clearly expressing the degree of input at message level. But I'm open to exploring oneof definition.

RelCommon common = 1;

// Points to a function_anchor defined in this plan, which must refer
// to a table function in the associated YAML file. Avoid using
// anchor/reference zero.
uint32 function_reference = 2;

// The arguments to be bound to the function. This must have exactly the
// number of arguments specified in the function definition from the YAML file,
// and the argument types must also match exactly:
// - Value arguments must be bound using FunctionArgument.value.
// Currently (0-input functions only), expressions must be constants
Comment thread
benbellick marked this conversation as resolved.
Outdated
// (literals or expressions evaluable without input data).
// - Type arguments must be bound using FunctionArgument.type.
// - Enum arguments must be bound using FunctionArgument.enum with a
// string that case-insensitively matches one of the allowed options.
repeated FunctionArgument arguments = 3;

// Options to specify behavior for corner cases, or leave behavior
// unspecified if the consumer does not need specific behavior in these
// cases.
repeated FunctionOption options = 5;

// Indicates whether the schema was derived from the YAML function definition:
// - If true: the table_schema was computed from the ExpressionNamedStruct in the
// YAML file by evaluating it with the bound argument types. This includes:
// * Static schemas (concrete field types)
// * Type-parameterized schemas (e.g., T from list<T>)
// - If false: the table_schema was determined by the plan producer based on runtime
// data inspection (YAML omits the return field)
Comment thread
benbellick marked this conversation as resolved.
Outdated
//
// This value must be true if and only if a return field is provided in the YAML
// definition of this function.

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 we simply have table_schema only? If it is empty, it must be from the YAML and should be able to be derived. Otherwise, we can just use the table_schema. If both are present, we'd better validate.

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.

I wanted to be consistent with the behavior of ScalarFunctions, which includes return types in the plans even though it is known from yaml files:

  message ScalarFunction {
    ...
    // Must be set to the return type of the function, exactly as derived
    // using the declaration in the extension.
    Type output_type = 3;
    ...
  }

I figured that it makes the plans more explicit to always include the table_schema, regardless of if things were derived or not. However, I still could drop the derived field. Then we just specify that the schema must conform to the implied schema in the yaml, if one is present. Otherwise, it can be anything set by the producer. What do you think?

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.

It is fine to have table_schema always but there is consequence -- it can be large especially with names. So I prefer to be able to omit the table_schema so that let consumer derive it from YAML. This is another point to add to dialect whether the implementation supports YAML based derivation. If the implementation does not support derivation, the producer must send explicit table_schema.

bool derived = 6;

// The concrete output schema of the relation. For derived schemas (derived=true),
// this must match the schema computed by evaluating the YAML ExpressionNamedStruct
// with the bound argument types. For runtime-dependent schemas (derived=false),
// this is provided by the plan producer.
NamedStruct table_schema = 7;

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 am wondering whether we can omit derived. Always having table_schema is fine and make it self-contained to some degree. Then, derived=true is implied and meaningful if and only if the engine is capable of handling YAML and YAML entry has struct there.

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.

I'm okay with omitting derived. I included it initially because I thought it made the dependency a little clearer between the YAML and the plan. But ultimately, libraries implementing the substrait spec will have to check consistency between the table_struct and the yaml anyways, so it doesn't matter that much.

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.

Leaving this conversation unresolved if others have something to say, but my most recent commit has dropped derived from the proto. Thanks!


substrait.extensions.AdvancedExtension advanced_extension = 8;
}

// A relation with output field names.
//
// This is for use at the root of a `Rel` tree.
Expand Down Expand Up @@ -581,6 +636,7 @@ message Rel {
WriteRel write = 19;
DdlRel ddl = 20;
UpdateRel update = 22;
TableFunctionRel table_function = 23;
// Physical relations
HashJoinRel hash_join = 13;
MergeJoinRel merge_join = 14;
Expand Down
15 changes: 15 additions & 0 deletions proto/substrait/function.proto
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,21 @@ message FunctionSignature {
}
}

message Table {
repeated Argument arguments = 2;
repeated string name = 3;
Description description = 4;

// The schema expression that defines the output relation structure.
// This must be an ExpressionNamedStruct since table functions always
// produce relations with named fields. The struct can be:
// - Static (concrete field types)
// - Type-parameterized (field types derived from argument types)
DerivationExpression.ExpressionNamedStruct schema = 5;

repeated Implementation implementations = 6;
}

message Description {
string language = 1;
string body = 2;
Expand Down
Loading
Loading