feat: interval day to second support - #183
Conversation
There was a problem hiding this comment.
Pull request overview
Adds IntervalDayToSecond support to the substrait-explain text format, covering both parsing (text → protobuf literal/type) and textification (protobuf → canonical text), and updates the documented grammar accordingly.
Changes:
- Adds
interval_daytyped literal parsing/textification using a compact duration-string encoding (e.g.'4d 5s':interval_day,'123456789ns':interval_day) with bounds validation. - Adds
interval_daytype support, including bareinterval_day(unset precision) and parameterizedinterval_day<precision>(0–12). - Extends grammar + tests for roundtrip coverage of the new type and literal behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/literal_roundtrip.rs | Adds a plan-level roundtrip test covering an interval_day literal. |
| src/types_tests.rs | Adds type/literal roundtrip assertions for interval_day and representative literals. |
| src/textify/types.rs | Emits bare interval_day when precision is unset; emits interval_day<precision> when set. |
| src/textify/expressions.rs | Implements IntervalDayToSecond literal textification as a duration string + adds validation tests. |
| src/parser/types.rs | Parses bare interval_day and parameterized interval_day<precision> with bounded precision validation. |
| src/parser/expressions.rs | Parses :interval_day typed string literals into IntervalDayToSecond with bounds/format checks. |
| src/parser/expression_grammar.pest | Adds interval_day to type grammar and introduces interval_day_type. |
| GRAMMAR.md | Documents interval_day typed-literal and type syntax/semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| interval_day_literal := duration_term (" " duration_term)* | ||
| duration_term := "-"? digit+ ("d" / "s" / "ms" / "us" / "ns" / "ps") | ||
| ``` |
| // An all-zero value at precision 0 collapses to the same "0s" as the | ||
| // all-zero default (precision 6): precision 0 has no subsecond term | ||
| // at all, so there's nothing to distinguish it from the default once | ||
| // days and seconds are also 0. Re-parsing "0s" always yields | ||
| // precision 6, so this specific value does not round-trip. |
462b480 to
b39deaf
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8809f5719f
ℹ️ 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".
| let (precision, subseconds) = match interval.precision_mode { | ||
| Some(PrecisionMode::Precision(p)) => (p, interval.subseconds), | ||
| Some(PrecisionMode::Microseconds(us)) => (6, us as i64), | ||
| None => (0, interval.subseconds), |
There was a problem hiding this comment.
Preserve unset interval precision as microseconds
When textifying protobufs where IntervalDayToSecond.precision_mode is unset, this maps the value to precision 0 and emits text like '5d':interval_day; parsing that text back creates Precision(0). The Substrait compatibility behavior for older/unset interval literals is microsecond precision, so inputs with precision_mode: None (common from older plans or default-valued protos) lose their precision on protobuf→text→protobuf round trips; this should emit a zero us component or otherwise preserve precision 6.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This is a good point but is potentially okay to leave as is. Zero makes the most sense as the default.
After looking into it further, I don't think this is a case worth covering. precision is required in the proto and the specs have no mention of being able to not set a precision.
| | "date" | ||
| | "time" | ||
| | "interval_year" | ||
| | "interval_day" |
There was a problem hiding this comment.
Why is this here? interval_day is not a 'simple type', its a compound type.
| // The trailing "<" lookahead is rejected so that "interval_day<6>" is left for | ||
| // interval_day_type (below) instead of being swallowed as bare "interval_day". | ||
| simple_type = { simple_type_name ~ nullability ~ !"<" } |
There was a problem hiding this comment.
Let's undo this as well; it shouldn't be here in simple_type.
| // The parameterized interval_day<precision> compound type. Bare "interval_day" | ||
| // (no parameters) is handled by simple_type_name above instead. |
There was a problem hiding this comment.
What is this about a 'bare' "interval_day" type? Why are we supporting that?
I... don't think we should do that. For every other type with parameters that we support, we require the parameters to be written out. I think we should do that here, as well.
Looking at the issue - I see I suggested '30s':interval_day; but I think leaving off the <0> was accidental there, and I don't know that we want to set a precedent for that.
|
|
||
| /// Split a single duration term (e.g. "5d", "-3s", "123ns") into its signed numeric | ||
| /// part and unit suffix. | ||
| fn split_duration_term<'a>( |
There was a problem hiding this comment.
I would suggest using Pest here; make a rule for it in Pest, which will be easier to read and give better error messages when it fails to parse, and then parse the pieces of it.
// One day term, one subseconds term, or both.
interval_day_duration = {
SOI ~ (
duration_day ~ (" " ~ duration_seconds)?
~ (" " ~ duration_subseconds)?
| duration_seconds ~ (" " ~ duration_subseconds)?
| duration_subseconds
) ~ EOI
}
duration_day = { integer ~ "d" }
duration_seconds = { integer ~ "s" }
duration_subseconds = { integer ~ ("ms" | "us" | "ns") }
Note also:
- This allows
d,s, andms/us/ns, in that order. Each is optional, at least one required. - It only allows a single space between, and no leading/trailing, and no tabs. That's a bit strict, but... seems reasonable? Do you really need any of this?
Then... this can go in the main grammar file, but I would suggest not including it as a sub-rule anywhere; I think that will get complicated - I'd just have the Pest parser parse this again, once you got to this level in the code, ExpressionParser::parse(Rule::interval_day_duration, unescaped_string).
| /// Mark a duration term category (e.g. "'d'", "subsecond (ns)") as seen, erroring | ||
| /// if it was already seen. Each unit category (`d`, `s`, one subsecond unit) may | ||
| /// appear at most once in a duration string. | ||
| fn mark_seen( |
There was a problem hiding this comment.
See above - with a Pest grammar like the above, the grammar would cover that.
| }; | ||
|
|
||
| const MAX_DAYS: i32 = 3_650_000; | ||
| if !(-MAX_DAYS..=MAX_DAYS).contains(&interval.days) { |
There was a problem hiding this comment.
While this is technically invalid substrait, I think you shouldn't refuse to write it - just write it, but also put an error into the error channel (via ctx.push_error).
| return write!( | ||
| w, | ||
| "{}", | ||
| ctx.failure(PlanError::invalid( |
There was a problem hiding this comment.
As above - push a warning, no need to ctx.failure here. The printer is supposed to be best-effort outputting, not strict - it should output anything it finds as long as it has a way to; failure tokens are only for cases where our syntax/grammar doesn't have a reasonable way to output it.
| ```text | ||
| interval_day_literal := duration_term (whitespace duration_term)? (whitespace duration_term)? | ||
| duration_term := "-"? digit+ duration_unit | ||
| duration_unit := "d" / "s" / "ms" / "us" / "ns" / "ps" |
There was a problem hiding this comment.
As above - I think it makes sense to:
- only allow a single space, not multiple, not tabs. We accept more in the broader grammar, but in literals, no need to.
- Only allow descending order, and only one subsecond unit.
- You can encode that into the PEG grammar here, too.
| - `interval_year`, `uuid` | ||
| - `interval_year`, `interval_day`, `uuid` | ||
|
|
||
| Bare `interval_day` is accepted as a type name, with an unset precision |
| )); | ||
| } | ||
|
|
||
| // Per the Substrait spec, IntervalDayToSecond supports a range of [-3,650,000..3,650,000] days. |
There was a problem hiding this comment.
Generally, we don't want to be stricter than necessary on Substrait - this isn't a validator, its a converter.
Generally - if 'invalid' Substrait has a clear syntax (as out-of-range seconds does), there's no need to go out of our way to reject it. Ideally, we would output a warning (as textify can), but in the parser, I'd suggest a // TODO note here about validation and just accept it.
feat: adding support for compound interval day type feat: cleaning up code fix: updating default value feat: cleaning up code feat: forcing precision suffix
Reuse the precision type from #178 instead of introducing a second one, and repair two function renames the rebase left behind. - Moved `SupportedPrecision` from `parser/expressions.rs` to a shared `precision` module, added a `Picoseconds` variant, and gave it the duration-unit mapping `interval_day` needs. The chrono-backed literals still reject precision 12 in `check_supported_precision`; `interval_day` accepts it, since sub-seconds are a plain integer count there. - `interval_day` literals now require a precision with a unit (0, 3, 6, 9, or 12), matching how precisiontimestamp/precisiontime literals behave. The type itself still accepts any precision from 0 to 12. - Type-level `parse_precision` stays a plain 0..=12 integer; narrowing to a writable precision is a literal concern. - `write_literal_type_suffix` gained an `interval_day` arm, replacing the bespoke suffix handling. - Restored `precision_timestamp_to_string` and `precision_time_to_string`, which the rebase had renamed and cross-wired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses review feedback on #183: an outer function owns the re-parse and the span/error handling, and a second function converts the resulting `Pair` into an `IntervalDayToSecond`. - `parse_interval_day_duration` unescapes-string input, runs `ExpressionParser::parse(Rule::interval_day_duration, ..)`, and turns either step's failure into a `MessageParseError` with the literal's span. - `interval_day_from_pair` takes the matched pair plus the ascribed precision and returns a new `IntervalDayError` for what it can find - a term too large for its protobuf field, or a sub-second unit that disagrees with the precision. No span is threaded through: the re-parsed string's spans point into a temporary, so the caller supplies the span. - `use pest::Parser as PestParser` moved to the top of the file, replacing the fully-qualified `<ExpressionParser as pest::Parser<Rule>>::parse`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e0fb04b to
ca574fb
Compare
Addresses review feedback on #183: an outer function owns the re-parse and the span/error handling, and a second function converts the resulting `Pair` into an `IntervalDayToSecond`. - `parse_interval_day_duration` unescapes-string input, runs `ExpressionParser::parse(Rule::interval_day_duration, ..)`, and turns either step's failure into a `MessageParseError` with the literal's span. - `interval_day_from_pair` takes the matched pair plus the ascribed precision and returns a new `IntervalDayError` for what it can find - a term too large for its protobuf field, or a sub-second unit that disagrees with the precision. No span is threaded through: the re-parsed string's spans point into a temporary, so the caller supplies the span. - `use pest::Parser as PestParser` moved to the top of the file, replacing the fully-qualified `<ExpressionParser as pest::Parser<Rule>>::parse`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ca574fb to
d214ffe
Compare
wackywendell
left a comment
There was a problem hiding this comment.
This is looking much better! The split between parse_interval_day_duration and interval_day_from_pair makes sense.
I have a couple code organization comments, but otherwise, LGTM!
| // Literal | ||
| literal = { (float | integer | boolean | string_literal | null) ~ (":" ~ sp ~ type)? } | ||
|
|
||
| // -- interval_day durations -- |
There was a problem hiding this comment.
This section should probably go at the end of the file, or even in its own separate file, since its not really part of the same "grammar" in the sense that its parsed separately...
| "interval_day_duration", | ||
| span, | ||
| format!( | ||
| "Invalid duration '{duration_str}': {}. Expected one to three terms, separated by single spaces, in the order days ('d'), seconds ('s'), sub-seconds ('ms', 'us', 'ns', or 'ps'); e.g. '5d', '4d 5s', '5d 3s 100ns'", |
| /// values recorded before `precision_mode` existed, and it matches the default | ||
| /// the type textifier uses for `Type.IntervalDay` with no precision. | ||
| fn interval_day_precision_units( | ||
| interval: &substrait::proto::expression::literal::IntervalDayToSecond, |
There was a problem hiding this comment.
This should be use statement at the top, not a long inline like this
| interval: &substrait::proto::expression::literal::IntervalDayToSecond, | |
| interval: &IntervalDayToSecond, |
| @@ -0,0 +1,105 @@ | |||
| //! Sub-second precision for literal values, shared by the parser and textifier. | |||
There was a problem hiding this comment.
👍 , good module. 🤔 Organizationally, it might make sense to find a home for this in a directory in src rather than top-level like this, but its fine for now - and good that its all in one file 👍
90171bb to
8c0667d
Compare
Description
Adds Substrait's IntervalDayToSecond type and literal to the text format, on both the parse and textify sides.
Type of Change
Testing
Related Issues
Closes #177