Skip to content
Open
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
77 changes: 76 additions & 1 deletion GRAMMAR.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,77 @@ Root[status]
# assert_eq!(plan.relations.len(), 1);
```

### Window Functions

A window function computes a value over a "window" of rows related to the current row (partitioning, ordering, and an optional frame), rather than collapsing rows the way an aggregate does.

#### Syntax

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

Rather than a bespoke grammar production per named argument, `over(...)` reuses the same
generic [`named_arguments`](#arguments) production used elsewhere (`name "=" argument`,
comma-separated). Rust code (not the grammar) decides which names are allowed in `over(...)`
and what shape each value must have:

- `partition=argument` - optional partitioning expression(s); a single field may be written
bare (`partition=$1`) or in a one-element tuple (`partition=($1,)`); multiple fields use a
multi-element tuple (`partition=($1, $2)`)
- `order=argument` - optional sort key(s); a single sort field is a bare 2-tuple of
`(reference, direction)` (e.g. `order=($3, &AscNullsLast)`); multiple sort fields are a
tuple of such 2-tuples (e.g. `order=(($3, &AscNullsLast), ($4, &DescNullsLast))`)
- `invocation=&Distinct` / `invocation=&All` - optional aggregation invocation
- `phase=&InitialToResult` (etc.) - required aggregation phase
- `rows=(lower, upper)` / `range=(lower, upper)` - optional, mutually exclusive window frame;
each bound is an integer (negative = preceding, positive = following, `0` = current row) or
`_` for unbounded

`partition=`, `order=`, `invocation=`, and `rows=`/`range=` are all optional and are omitted when empty; `phase=` is always required and always printed. The grammar itself only requires that at least one named argument be present; the specific rules — that `phase=` must appear, that `rows=`/`range=` are mutually exclusive, and that a `range=` frame have exactly one `order=` field — are all enforced by the parser rather than by the grammar.

#### Examples

```rust
# use substrait_explain::Parser;
#
# let plan_text = r#"
=== Extensions
URNs:
@ 1: https://github.com/substrait-io/substrait/blob/main/extensions/functions_aggregate.yaml
Functions:
## 10 @ 1: sum

=== Plan
Root[a, b, s]
Project[$0, $1, sum($1) over(phase=&InitialToResult, order=($1, &AscNullsLast), invocation=&Distinct, partition=$0, rows=(-3, 0)):fp64?]
Read[t => a:i32, b:fp64]
# "#;
#
# let plan = Parser::parse(plan_text).unwrap();
# assert_eq!(plan.relations.len(), 1);
```

Ranking-style window functions have no meaningful frame, so the `rows=`/`range=` clause is omitted entirely:

```rust
# use substrait_explain::Parser;
#
# let plan_text = r#"
=== Extensions
URNs:
@ 1: https://github.com/substrait-io/substrait/blob/main/extensions/functions_arithmetic.yaml
Functions:
## 10 @ 1: row_number

=== Plan
Root[a, r]
Project[$0, row_number() over(phase=&InitialToResult, order=($0, &AscNullsLast), partition=$0):i64]
Read[t => a:i32]
# "#;
#
# let plan = Parser::parse(plan_text).unwrap();
# assert_eq!(plan.relations.len(), 1);
```

## Relations

Relations represent the operations in a query plan. Each relation is displayed on a single line with indentation showing the hierarchy.
Expand Down Expand Up @@ -536,14 +607,18 @@ Arguments in relations can be literals, expressions, enums, or tuples thereof.
#### Syntax

```text
argument := enum / reference / literal / expression / tuple
argument := enum / reference / literal / expression / tuple / "_"
tuple := "(" ")" // 0-tuple
/ "(" argument "," ")" // 1-tuple (trailing comma required)
/ "(" argument ("," argument)+ ","? ")" // 2+-tuple (trailing comma optional)
arguments := argument ("," argument)*
named_arguments := name "=" argument ("," name "=" argument)*
```

`_` is accepted syntactically as an argument (it denotes an unbounded window frame
bound in `over(...)`), but is only meaningful there; consumers that don't support it —
extension-relation arguments, for example — reject it during parsing.

Tuples follow the Python/Rust trailing-comma convention to disambiguate from parenthesised
expressions: `(x)` is a parenthesised expression, not a tuple. A trailing comma is required to
form a 1-element tuple: `(x,)`. For 2+ elements the trailing comma is optional: `(x, y)` and
Expand Down
93 changes: 93 additions & 0 deletions src/parser/common.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use std::collections::HashMap;
use std::fmt;

use pest_derive::Parser as PestDeriveParser;
use substrait::proto::sort_field::SortDirection;
use thiserror::Error;

use crate::extensions::SimpleExtensions;
Expand Down Expand Up @@ -315,6 +317,97 @@ impl Drop for RuleIter<'_> {
}
}

/// A collection of named arguments (`name=value` pairs) extracted from a
/// named-argument-list rule, keyed by name with duplicate-name rejection.
///
/// Shared by every consumer of the generic `name=value` grammar — the `Fetch`
/// relation's `limit=`/`offset=`, and window functions' `over(...)` arguments.
/// It lives here in `common` (rather than in `relations` or `expressions`) so
/// that neither of those modules has to depend on the other for it.
///
/// The fluent API ensures all arguments are processed exactly once and none are
/// forgotten: [`pop`](Self::pop) consumes a known argument, and
/// [`done`](Self::done) errors on any that remain unconsumed.
pub(crate) struct ParsedNamedArgs<'a> {
map: HashMap<&'a str, pest::iterators::Pair<'a, Rule>>,
}

impl<'a> ParsedNamedArgs<'a> {
pub(crate) fn new(
pairs: pest::iterators::Pairs<'a, Rule>,
rule: Rule,
) -> Result<Self, MessageParseError> {
let mut map = HashMap::new();
for pair in pairs {
assert_eq!(pair.as_rule(), rule);
let mut inner = pair.clone().into_inner();
let name_pair = inner.next().unwrap();
let value_pair = inner.next().unwrap();
assert_eq!(inner.next(), None);
let name = name_pair.as_str();
if map.contains_key(name) {
return Err(MessageParseError::invalid(
"NamedArg",
name_pair.as_span(),
format!("Duplicate argument: {name}"),
));
}
map.insert(name, value_pair);
}
Ok(Self { map })
}

// Returns the pair if it exists and matches the rule, otherwise None.
// Asserts that the rule must match the rule of the pair (and therefore
// panics in non-release-mode if not)
pub(crate) fn pop(
mut self,
name: &str,
rule: Rule,
) -> (Self, Option<pest::iterators::Pair<'a, Rule>>) {
let pair = self.map.remove(name).inspect(|pair| {
assert_eq!(pair.as_rule(), rule, "Rule mismatch for argument {name}");
});
(self, pair)
}

// Returns an error if there are any unused arguments.
pub(crate) fn done(self) -> Result<(), MessageParseError> {
if let Some((name, pair)) = self.map.iter().next() {
return Err(MessageParseError::invalid(
"NamedArgExtractor",
// No span available for all unused args; use default.
pair.as_span(),
format!("Unknown argument: {name}"),
));
}
Ok(())
}
}

/// Map a sort-direction enum identifier (without the leading `&`) to a
/// [`SortDirection`]. Shared by the `Sort` relation's `sort_field` parser and
/// the window function's `order=` parser, which reach it from different grammar
/// rules (`sort_direction` vs a generic `enum_value`) but accept the same set
/// of variant names. Lives in `common` so neither `relations` nor
/// `expressions` depends on the other for it.
pub(crate) fn sort_direction_from_str(
name: &str,
span: pest::Span,
) -> Result<SortDirection, MessageParseError> {
match name {
"AscNullsFirst" => Ok(SortDirection::AscNullsFirst),
"AscNullsLast" => Ok(SortDirection::AscNullsLast),
"DescNullsFirst" => Ok(SortDirection::DescNullsFirst),
"DescNullsLast" => Ok(SortDirection::DescNullsLast),
other => Err(MessageParseError::invalid(
"SortDirection",
span,
format!("Unknown sort direction: {other}"),
)),
}
}

#[cfg(test)]
pub(crate) mod test_support {
use pest::Parser as PestParser;
Expand Down
41 changes: 37 additions & 4 deletions src/parser/expression_grammar.pest
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,19 @@ type = { simple_type | compound_type | user_defined_type }

argument_list = { "(" ~ (expression ~ (sp ~ "," ~ sp ~ expression)*)? ~ ")" }

// Scalar function call:
// An unresolved reference to a function:
// - Compound function name (base name or base:signature, e.g. "equal" or "equal:any_any")
// - Optional Anchor, e.g. #1
// - Optional URN Anchor, e.g. @1
function_reference = { function_signature ~ sp ~ anchor? ~ sp ~ urn_anchor? }

// Scalar function call:
// - A function_reference
// - Arguments `()` are required, e.g. (1, 2, 3)
// - Required output type annotation after closing paren, e.g. :i64
// (unambiguous because function_signature is atomic and ends before the opening paren)
function_call = {
function_signature ~ sp ~ anchor? ~ sp ~ urn_anchor? ~ sp ~ argument_list ~ ":" ~ sp ~ type
function_reference ~ sp ~ argument_list ~ ":" ~ sp ~ type
}

if_clause = {
Expand All @@ -179,10 +183,34 @@ cast_failure_behavior = { "?" | "!" }
// (78:i32)::!i16 THROW_EXCEPTION
cast_expression = { "(" ~ sp ~ expression ~ sp ~ ")" ~ sp ~ "::" ~ sp ~ cast_failure_behavior? ~ sp ~ type }

// -- Window Function Calls --
//
// WindowFunction[Expression.WindowFunction]: a function_reference and
// argument_list followed by an "over(...)" clause carrying named arguments.
// Example: sum($0) over(partition=($1,$2), order=($3,&AscNullsLast), rows=(-3, 0), phase=&InitialToResult, invocation=&Distinct):fp64
//
// - `partition=(...)` - optional list of partitioning expressions; a single
// partition field may be written bare (`partition=$1`) or in a one-element
// tuple (`partition=($1,)`); multiple fields use a multi-element tuple.
// - `order=...` - optional sort key(s); a single sort field is a bare 2-tuple
// of (reference, direction) (e.g. `order=($3,&AscNullsLast)`); multiple sort
// fields are a tuple of such 2-tuples (e.g. `order=(($3,&AscNullsLast),($4,&DescNullsLast))`)
// - `rows=(lower,upper)` / `range=(lower,upper)` - optional frame bounds (mutually
// exclusive), encodes `bounds_type` with rows and range.
// Each bound is an integer offset (negative preceding, positive following, 0
// for the current row) or `_` for unbounded/unspecified.
// - `phase=&...` - required aggregation phase
// - `invocation=&...` - optional invocation (e.g. `&All`, `&Distinct`)
window_function_call = {
function_reference ~ sp ~ argument_list ~ sp ~ "over" ~ sp ~ "(" ~ sp ~ extension_named_arguments ~ sp ~ ")" ~ ":" ~ sp ~ type
}

// Top-level Expression Rule
// Order matters for PEGs: Since an identifier can be a function call, we put that first.
// cast_expression must come before literal/function_call since it starts with "("
expression = { if_then | cast_expression | function_call | reference | literal }
// window_function_call must come before function_call: both share the same
// prefix, but window_function_call requires the trailing "over(...)" clause.
expression = { if_then | cast_expression | window_function_call | function_call | reference | literal }
Comment thread
gord02 marked this conversation as resolved.

// == Extensions ==
// These rules are for parsing extension declarations, by line.
Expand Down Expand Up @@ -388,7 +416,12 @@ tuple = {
~ (sp ~ "," ~ sp ~ extension_argument)+
~ (sp ~ ",")? ~ sp ~ ")"
}
extension_argument = { enum_value | untyped_literal | reference | expression | tuple }
// The generic argument-value grammar, shared by extension-relation arguments
// and window-function `over(...)` arguments. `empty` ("_") is syntactically
// valid everywhere; each consumer's Rust parser decides where it is
// semantically meaningful (window frame bounds) versus an error (everywhere
// else - see `ExtensionValue::parse_pair`).
extension_argument = { enum_value | untyped_literal | reference | expression | tuple | empty }

// Named arguments (name=value pairs)
extension_named_arguments = { extension_named_argument ~ (sp ~ "," ~ sp ~ extension_named_argument)* }
Expand Down
Loading