Skip to content

feat: implementing window function expressions support - #185

Open
gord02 wants to merge 9 commits into
mainfrom
gordon.hamilton/window-function-implementation
Open

feat: implementing window function expressions support#185
gord02 wants to merge 9 commits into
mainfrom
gordon.hamilton/window-function-implementation

Conversation

@gord02

@gord02 gord02 commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds parsing and textifying for Substrait's Expression.WindowFunction, following the existing scalar/aggregate function syntax with an over(...) clause for window-specific args.

Syntax: func(args) over(phase=..., partition=(...), order=..., invocation=..., rows=(lo, hi) | range=(lo, hi)):type

  • phase= (required) and invocation= map to AggregationPhase/AggregationInvocation.
  • partition=(...) — partitioning expressions.
  • order= — sort field(s); bare for a single field, parenthesized list of tuples for multiple, matching Sort's sort_field_list convention.
  • rows=(lo, hi) / range=(lo, hi) — frame bounds, where each side is an integer offset, 0 for current row, or _ for unbounded (parsed to an explicit Bound::Unbounded, not left as None).
  • range= is rejected when more than one order= field is present, matching the proto's semantics.

Changes:

  • src/parser/expression_grammar.pest — new grammar rules for window_function_call and its over(...) args.
  • src/parser/expressions.rsWindowFunction::parse_pair.
  • src/textify/expressions.rs — corresponding textifier.
  • GRAMMAR.md — documents the new syntax.
  • tests/plan_roundtrip.rs — round-trip integration tests, alongside new unit tests in the parser/textify modules.

Type of Change

  • New feature

Testing

  • Added tests for new functionality
  • All existing tests pass

Related Issues

Closes #158

Comment thread src/parser/expressions.rs

Copilot AI left a comment

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.

Pull request overview

This PR adds end-to-end support for Substrait Expression.WindowFunction in substrait-explain, introducing a func(args) over(...) : type surface syntax and wiring it through the Pest grammar, Rust parser, and textifier so window functions can round-trip between text format and protobuf.

Changes:

  • Extends the expression grammar to recognize window_function_call with over(...) named args (phase/partition/order/invocation/frame).
  • Implements parsing into substrait::proto::expression::WindowFunction and textification back to the new syntax.
  • Adds round-trip and unit tests plus user-facing documentation updates in GRAMMAR.md.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/plan_roundtrip.rs Adds integration round-trip coverage for window function plans (including unbounded bounds + invalid range/order combos).
src/textify/expressions.rs Implements WindowFunction textification and hooks it into RexType::WindowFunction.
src/parser/expressions.rs Implements WindowFunction parsing and allows window calls as general expressions.
src/parser/expression_grammar.pest Adds Pest rules for window function calls and their over(...) argument forms.
GRAMMAR.md Documents the new window function syntax and provides doctest examples.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/textify/expressions.rs Outdated
Comment on lines +563 to +572
/// Write a single sort field as `($ref,&Direction)`, for use in `order=`.
fn textify_sort_field<S: Scope, W: fmt::Write>(sf: &SortField, ctx: &S, w: &mut W) -> fmt::Result {
let expr = ctx.expect(sf.expr.as_ref());
write!(w, "({expr},")?;
match sort_direction_str(sf.sort_kind.as_ref()) {
Ok(s) => textify_enum(s, ctx, w)?,
Err(e) => write!(w, "{}", ctx.failure(e))?,
}
write!(w, ")")
}
Comment thread src/textify/expressions.rs Outdated
Comment on lines +686 to +691
let bounds_type = window_function::BoundsType::try_from(self.bounds_type);
let has_bounds = self.lower_bound.is_some() || self.upper_bound.is_some();
if !matches!(bounds_type, Ok(window_function::BoundsType::Unspecified)) || has_bounds {
let keyword = match bounds_type {
Ok(window_function::BoundsType::Rows) => "rows",
Ok(window_function::BoundsType::Range) => "range",
Comment thread src/parser/expressions.rs Outdated
Comment on lines +577 to +588
let mut partitions = Vec::new();
let mut sorts = Vec::new();
let mut invocation = AggregationInvocation::Unspecified as i32;
let mut phase = None;
let mut bounds_type = window_function::BoundsType::Unspecified as i32;
let mut lower_bound = None;
let mut upper_bound = None;

for arg in named_arg_list.into_inner() {
assert_eq!(arg.as_rule(), Rule::window_named_arg);
let inner = unwrap_single_pair(arg);
match inner.as_rule() {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

is this case worth addressing?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes - we should probably either catch these and error, or at least a TODO comment, rather than silently ignore.

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread src/parser/expressions.rs Outdated
Comment thread src/parser/expressions.rs Outdated
@gord02
gord02 force-pushed the gordon.hamilton/window-function-implementation branch from 5a7c968 to 6326640 Compare July 16, 2026 17:27
@gord02
gord02 requested a review from Copilot July 16, 2026 17:28
@gord02
gord02 marked this pull request as ready for review July 16, 2026 17:29
@gord02
gord02 requested review from a team and wackywendell as code owners July 16, 2026 17:29

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread src/parser/expressions.rs Outdated
Comment on lines +562 to +573
let mut partitions = Vec::new();
let mut sorts = Vec::new();
let mut invocation = AggregationInvocation::Unspecified as i32;
let mut phase = None;
let mut bounds_type = window_function::BoundsType::Unspecified as i32;
let mut lower_bound = None;
let mut upper_bound = None;

for arg in named_arg_list.into_inner() {
assert_eq!(arg.as_rule(), Rule::window_named_arg);
let inner = unwrap_single_pair(arg);
match inner.as_rule() {
Comment thread src/textify/expressions.rs Outdated
Comment on lines +640 to +642
// The parser rejects range= frames unless there is
// exactly one order= field; enforce the same invariant
// here so textified output always re-parses.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 63266404a8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

match bound.and_then(|b| b.kind.as_ref()) {
None | Some(bound::Kind::Unbounded(_)) => Value::EmptyGroup,
Some(bound::Kind::CurrentRow(_)) => Value::Integer(0),
Some(bound::Kind::Preceding(p)) if p.offset < 0 => Value::Missing(PlanError::invalid(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject zero preceding/following offsets

When a protobuf contains a malformed window bound such as Preceding { offset: 0 }, this falls through and emits 0, which reparses as CurrentRow and silently changes the bound kind. The Substrait proto comments require preceding/following offsets to be strictly positive and say to use CurrentRow for zero, so the textifier should surface zero the same way it reports negative offsets instead of producing lossy text.

Useful? React with 👍 / 👎.

@wackywendell wackywendell left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a good start for window-function support, especially the round-trip coverage. I think the parser will be easier to extend if window functions reuse the existing argument and tuple syntax, with Rust responsible for interpreting the named fields. On the formatting side, the expressions module should not depend on the rels module; it should only go the other way to make the separation of concerns clearer, and we should reorganize as needed to achieve that.

There are also a couple of conversion cases to tighten up: representable range frames should not be rejected during parsing, and malformed protobuf bounds should produce diagnostics rather than silently changing variants. I'd like another pass on those boundaries before approval, but the overall direction looks good.

Comment thread src/parser/expression_grammar.pest Outdated
window_phase_arg = { "phase" ~ sp ~ "=" ~ sp ~ enum_value }
window_invocation_arg = { "invocation" ~ sp ~ "=" ~ sp ~ enum_value }

window_named_arg = { window_partition_arg | window_order_arg | window_frame_arg | window_phase_arg | window_invocation_arg }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we reuse the common argument value, tuple, and named-argument grammar here, rather than define a separate production for each window field? The current approach gives window functions a parallel argument syntax, even though Rust still needs to interpret the fields afterwards.

I would expect Pest to parse the same generic values we use elsewhere, with the window parser then deciding which names are allowed and what shape each value must have. That would also give us one place in Rust to reject duplicate fields, require phase, and make rows and range mutually exclusive.

Tuple syntax should work the same way here as it does elsewhere: (x,) is a one-element tuple and (x, y) is a two-element tuple. Where a window field represents a list, its conversion can accept either one element or a tuple. For example, order=($0, &AscNullsLast) is one sort field, while order=(($0, &AscNullsLast),) is an explicit one-element tuple of sort fields. Canonical output can continue to use the shorter form.

Comment thread src/parser/expressions.rs Outdated
}

/// The shared prefix of function calls.
struct FunctionHead {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not sure FunctionHead is the right abstraction here. It includes the function arguments, but leaves the output type and window clause for the caller to parse. Its name describes where it appears in the input rather than a complete thing in the syntax, so callers still need to know which pieces remain in the iterator.

Could we instead parse an unresolved function reference, arguments, an over clause, and the output type as meaningful components? Those could compose into complete ScalarFunctionInvocation and WindowFunctionInvocation syntax types. Conversion from those types could then resolve the function reference and construct the protobuf message.

Comment thread src/parser/expressions.rs Outdated
}

/// Parse the `CompoundName ~ anchor? ~ urn_anchor? ~ argument_list` prefix of functions.
fn parse_function_head(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could the syntax types above implement the existing pair-parsing traits? Right now parse_function_head is a standalone function, even though ParsePair and ScopedParsePair normally connect a grammar rule directly to the Rust type it produces.

Function arguments need extension context, so ScopedParsePair seems like the natural fit for the invocation types. That would keep the rule and its parsing behavior on the parsed type, instead of asking callers to coordinate an iterator with a separate helper.

Comment thread src/textify/expressions.rs Outdated

use super::{PlanError, Scope, Textify, Visibility};
use crate::extensions::simple::ExtensionKind;
use crate::textify::rels::{Arguments, NamedArg, Value, ValueEnum, enum_str_value};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we avoid making expression formatting depend on the relation formatter? Arguments, NamedArg, Value, and ValueEnum describe shared text-format concepts, but they currently live in textify::rels. This change also puts the window-function enum implementations there, which makes it less clear which module owns this behavior.

One option would be to move Value, NamedArg, and the related enum conversion into a neutral textify module; you could also (instead?) move window functions into their own module. I'm open to other arrangements, but let's try and make them fairly well-defined/separate so that relations can depend on expressions (and treat it as a 'black box'), and not vice versa.

Comment thread src/textify/expressions.rs Outdated
}
}

fn window_enum_value<'a, T: TryFrom<i32> + ValueEnum>(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This interface looks like a symptom of the module boundary above. window_enum_value is generic over the protobuf enum type, but hard-codes WindowFunction in its errors. It also combines protobuf decoding, diagnostics, and formatting, and its return lifetime is unrelated to any input because ValueEnum only provides a borrowed conversion.

Could we give decoded enum values a direct, owned conversion into the shared formatting value instead? The window textifier could then keep responsibility for decoding its raw fields and reporting field-specific errors. I would prefer that over renaming or further generalizing this helper.

Comment thread src/parser/expressions.rs Outdated
Comment on lines +577 to +588
let mut partitions = Vec::new();
let mut sorts = Vec::new();
let mut invocation = AggregationInvocation::Unspecified as i32;
let mut phase = None;
let mut bounds_type = window_function::BoundsType::Unspecified as i32;
let mut lower_bound = None;
let mut upper_bound = None;

for arg in named_arg_list.into_inner() {
assert_eq!(arg.as_rule(), Rule::window_named_arg);
let inner = unwrap_single_pair(arg);
match inner.as_rule() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes - we should probably either catch these and error, or at least a TODO comment, rather than silently ignore.

Comment on lines +411 to +413
/// Write the `name(args, options)` prefix shared by `ScalarFunction`,
/// `AggregateFunction`, and `WindowFunction` textification.
fn textify_function_call_prefix<S: Scope, W: fmt::Write>(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

As with the parser - I think this makes more sense to break into components: function name/reference; arguments; over clause; output type. Implement Textify for each of these (perhaps by creating a newtype for each, as needed), then Textify for a FunctionCall and for a WindowFunctionCall can just make four calls to their elements.

Comment thread tests/plan_roundtrip.rs

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Good tests 👍

Comment thread GRAMMAR.md Outdated

#### Syntax

`window_function := function_signature anchor? urn_anchor? "(" (expression ("," expression)*)? ")" "over(" (window_named_arg ("," window_named_arg)*)? ")" ":" type`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

As in the Pest grammar, I would define the arguments in over(…) as named_arguments here, which are already defined.

Below, defining the specific arguments allowed in terms of a "signature" for each (e.g. partition is an expression, or tuple of expressions), using terms as defined above, would be good.

}

#[test]
fn test_window_function_full() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There are a lot of complicated tests here, and by their nature, they are a bit hard to follow; do we need all of them, or are they covered by the roundtrip tests?

@gord02

gord02 commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

A lot has changed in this last review so I tried to summarize the changes claude made:


Grammar: reuse the generic argument grammar for over(...)

Resolution: Deleted the five window-specific productions (window_value/window_tuple/window_named_arg/window_named_arg_list). over(...) now uses the generic extension_argument / tuple / extension_named_arguments rules. To let _ (unbounded bound) be a tuple element, empty was added to extension_argument; ExtensionValue::parse_pair (src/parser/extensions.rs) now rejects _ with a typed MessageParseError. All shape/semantic interpretation lives in Rust (OverClause), which is the single place that rejects duplicates/unknowns (ParsedNamedArgs), requires phase=, and enforces rows/range exclusivity.

  • src/parser/expression_grammar.pest, src/parser/extensions.rs, src/parser/expressions.rs (OverClause)

Confusing Option<Bound> return / use a trait

Comment — wackywendell, expressions.rs:495 (3605333063): "what does None mean? … good candidate for impl ParsePair for window_function::Bound."

Resolution: Replaced the standalone parse_window_bound (which never actually returned None) with impl ScopedParsePair for window_function::Bound returning
Result<Bound, _>. ScopedParsePair rather than ParsePair because the foreign type can't take an inherent impl (orphan rules) and the checked_neg path must be
fallible; the _extensions param mirrors the existing SortField precedent.

Silently-ignored malformed window arg shapes

Comment — wackywendell, expressions.rs:588 (3605337174): "catch these and error … rather than silently ignore."

Resolution: The new arg helpers (parse_window_order, parse_window_frame, sort_field_from_tuple) return MessageParseError on wrong shapes/counts (via
<[_; 2]>::try_from) instead of asserting/ignoring. Covered by the non-integer and wrong-arity cases in the rejection table.

  • src/parser/expressions.rs

FunctionHead abstraction / trait-based parsing

Comments — wackywendell, expressions.rs:405 (3604792068) and :412 (3604792070): FunctionHead names where it sits in the input and leaves pieces
in the caller's iterator; parse meaningful components as syntax types that implement the pair-parsing traits.

Resolution: Deleted FunctionHead/parse_function_head. Introduced FunctionReference (unresolved name+anchor, impl ParsePair, with a resolve()
that does the registry lookup) and a new named function_reference grammar rule. WindowFunctionInvocation + OverClause (ScopedParsePair) compose the pieces
and a resolve() builds the protobuf. ScalarFunction::parse_pair composes FunctionReference + parse_argument_list + type directly (a transient
ScalarFunctionInvocation layer was added then collapsed, per the follow-up review, to keep one-type-per-rule for the scalar path).

  • src/parser/expression_grammar.pest, src/parser/expressions.rs

Expression formatter depending on the relation formatter

Comment — wackywendell, textify/expressions.rs:22 (3604792073): move Value/NamedArg/Arguments/ValueEnum out of textify::rels so relations can
depend on expressions and not vice versa.

Resolution: Extracted them (plus ArgsLayout, enum_str_value) into a new neutral src/textify/values.rs. Both rels and expressions now depend
downward on it. The one residual back-dependency (values → expressions::Reference) was removed by inlining write!("${i}"), so values.rs is a true leaf.

  • src/textify/values.rs (new), src/textify/rels.rs, src/textify/expressions.rs, src/textify/mod.rs

window_enum_value helper (symptom of the module boundary)

Comment — wackywendell, textify/expressions.rs:544 (3604792076): generic helper hard-codes WindowFunction in errors and bundles decode+diagnostic+format;
give decoded enums a direct owned conversion into the shared value.

Resolution: Removed window_enum_value. The window textifier decodes its raw i32 field via the shared decode_enum_field::<T>() (in values.rs), which
reports a field-specific error tagged with the enum's own message type (AggregationPhase / AggregationInvocation) and uses the owned enum_str_value
conversion. decode_enum_field is also applied to SetRel to remove the same decode duplication.

  • src/textify/values.rs, src/textify/expressions.rs, src/textify/rels.rs

Malformed bounds silently become valid variants

Comments — wackywendell textify/expressions.rs:690 (3604792080) and chatgpt-codex :710 (3597639246): Preceding/Following offset 0 renders as
0 (re-parses as CurrentRow); None and Some(Bound{kind:None}) both render as _`; reject zero offsets and distinguish the malformed case.

Resolution: window_bound_value now distinguishes an absent bound (None_, valid) from a present-but-empty bound (Some(kind:None) → diagnostic), and
emits a diagnostic for Preceding/Following offset 0 instead of rendering it. Covered by test_window_function_bound_with_no_kind_surfaces_error and
test_window_function_zero_offset_bound_surfaces_error.

  • src/textify/expressions.rs

Split the window Textify impl into components

Comment — wackywendell, textify/expressions.rs:413 (3605352192): break into name/reference, arguments, over-clause, output type.

Resolution: impl Textify for WindowFunction is now prefix → over-clause → output type, delegating the over-clause to window_over_named_args / window_frame_named_arg / window_partition_value.

  • src/textify/expressions.rs

GRAMMAR.md over(...) docs

Comment — wackywendell, GRAMMAR.md:510 (3605385079): define over(…) args as the already-defined named_arguments, with a per-field signature.

Resolution: The window section now references the generic named_arguments production and documents each field's signature (partition=, order=,
rows=/range=, phase=, invocation=).

  • GRAMMAR.md

Test-related

Comment — wackywendell, textify/expressions.rs:1403 (3605406192): "a lot of complicated tests … do we need all of them, or are they covered by the roundtrip
tests?"

Resolution: The textify-only tests are kept — they exercise malformed / hand- built protos (kind:None, zero offsets, unknown enum i32s, wrong sort counts)
that the parser cannot produce, so round-trip tests structurally can't reach them. Separately, the parser-side rejection tests were consolidated from many one-off
functions into a single table-driven test_window_function_invalid_over_clauses_rejected (matching the repo's existing for input in … test style).

  • src/parser/expressions.rs, tests/plan_roundtrip.rs

Known limitation, intentionally not fixed here

SortDirection::Clustered textifies as &Clustered but is not accepted by the sort_direction grammar rule / parser, so a Clustered sort does not round-trip.
This is pre-existing on main (affects the Sort relation independently of window functions); this PR neither introduces nor fixes it. Best addressed as a
separate change that also covers the Sort relation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Support for WindowFunction

3 participants