Skip to content

fix(isthmus)!: convert temporal literals at their declared type - #1121

Open
alexandrefimov wants to merge 6 commits into
substrait-io:mainfrom
alexandrefimov:issue-1113-temporal-literals
Open

fix(isthmus)!: convert temporal literals at their declared type#1121
alexandrefimov wants to merge 6 commits into
substrait-io:mainfrom
alexandrefimov:issue-1113-temporal-literals

Conversation

@alexandrefimov

@alexandrefimov alexandrefimov commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

LiteralConverter.convert computes the Substrait type of the literal it is handed on its first line, then does not use it in the temporal cases. TIME and TIMESTAMP were reported at a fixed precision 6 with the value rescaled to microseconds, and TIMESTAMP_WITH_LOCAL_TIME_ZONE shared the TIMESTAMP branch and came back as a plain precision_timestamp. TypeConverter.toSubstrait maps those same Calcite types to precision_time<P>, precision_timestamp<P> and precision_timestamp_tz<P>, so the two halves of one conversion disagreed about the same literal.

That is not only a round trip losing the producer's choice — SQL literals carry their own precision, so it is what Isthmus emits:

TIMESTAMP '2024-01-01 00:00:00'      -> Calcite TIMESTAMP(0) -> precision_timestamp<6>
TIME '12:30:45'                      -> Calcite TIME(0)      -> precision_time<6>

Take the precision from the result type and express the value at it, and route TIMESTAMP_WITH_LOCAL_TIME_ZONE to precisionTimestampTZ. At precision 6 the arithmetic is unchanged; other precisions round-trip instead of being widened.

In the other direction, the maximum-precision check for a precision_timestamp_tz literal looked up SqlTypeName.TIMESTAMP_TZ. SubstraitTypeSystem does not configure that name — it overrides TIMESTAMP_WITH_LOCAL_TIME_ZONE — so the lookup fell through to Calcite's default of 3 and rejected a microsecond TZ literal, while the corresponding type conversion accepted it. It now checks the name everything else TZ-related uses.

Emitting the declared precision only works if the reverse direction takes it back, and it did not: getTimestampString and createTimeString handled 0, 3, 6 and 9 and threw for everything else, so TIMESTAMP '2024-01-01 00:00:00.12' — precision 2, from the fractional digits written — produced a plan Isthmus could not read. algebra.proto documents what 0, 3, 6, 9 and 12 mean and leaves the field an unbounded int32, so the unit is now 10^-precision at any precision a TimeString or TimestampString carries. Precisions 1, 2, 4 and 5 were listed in ExpressionConvertabilityTest as unsupported for both timestamp literals; they moved to the supported side.

Two smaller things. A temporal type built from a Java class rather than a SQL type name carries PRECISION_NOT_SPECIFIED, which nothing read: a java.sql.Time field of a reflective schema became precision_time<-1> with the value divided by 10^10, and the timestamp side failed inside Guava. Both the type and the literal now read the sentinel as precision 0, the way Calcite's own RexBuilder.clean does. And the precision check, written out six times and identical apart from a SqlTypeName and a hand-typed noun, is now SubstraitTypeSystem.requireSupportedPrecision — a static, since TypeConverter and ExpressionRexConverter have no common ancestor to hang an instance method on.

Built on #1127, which rewrites the same two helpers; its two commits are the first two here.

Closes #1113. Finishes #1114, whose interval third is in #1120.

BREAKING CHANGE: a Calcite TIME or TIMESTAMP literal now converts to a Substrait literal carrying its own precision rather than always precision 6, so a plan built from TIMESTAMP '2024-01-01 00:00:00' declares precision_timestamp<0> with a value in seconds where it previously declared precision_timestamp<6> with a value in microseconds. A TIMESTAMP WITH LOCAL TIME ZONE literal converts to precision_timestamp_tz instead of precision_timestamp.

@alexandrefimov

Copy link
Copy Markdown
Contributor Author

Two things carried over from the #1120 review, so they do not have to be said twice.

The rescale now uses LongMath.pow instead of a hand-rolled loop, matching what #1120 ended up with. A shared home for it — DecimalUtil.POWER_OF_10 is private today — belongs with the checkPrecision(...) extraction you suggested doing once this lands, since this PR is what un-drifts the TZ copy.

I also checked whether the rounding defect you found in #1120 exists here. It does not, and for a reason worth stating: Calcite hands the literal over already at its declared precision, so there is no narrowing decision on this side. Where narrowing does show, a timestamp floors rather than truncating towards the epoch — 1969-12-31 23:59:59.5 at second precision is 23:59:59, not the epoch — which is the opposite of the interval case, where the value is a duration and narrowing shortens it. That is now a comment and a test rather than something a reader has to reconstruct.

While checking it I hit an unrelated, pre-existing bug: a pre-epoch precision_timestamp with a fractional second throws on the way to Calcite, because getTimestampString truncates towards zero and then hands TimestampString.withNanos a negative value. Reproduces on main at precision 6 too, so it is not from this change — filed as #1126.

@alexandrefimov

Copy link
Copy Markdown
Contributor Author

Self-review pass before this sits waiting, in the shape of the #1120 one.

Checked at runtime rather than by reading, and two of the three came back clean:

  • resultType.getPrecision() cannot exceed 6 here. createSqlType(TIMESTAMP, 9) returns TIMESTAMP(6) under SubstraitTypeSystem — Calcite clamps to the type-system maximum — so the new epochSeconds * 10^P cannot overflow any further than the old epochSeconds * 10^6 did.
  • Calcite reports TIMESTAMP(0) / TIME(0) for a type built without an explicit precision, not PRECISION_NOT_SPECIFIED, so there is no -1 reaching the rescale. If one ever did, LongMath.pow rejects a negative exponent rather than computing something.
  • The gap: the BREAKING CHANGE footer is about the plans Isthmus emits from SQL, and nothing asserted that half. Added in 76d6af8TIMESTAMP '2024-01-01 00:00:00' now yields precision_timestamp<0> with a value in seconds, TIMESTAMP '...00:00:00.123' yields <3>, TIME '12:30:45' yields precision_time<0>. Reverting LiteralConverter to its main version fails it with precision=6 on both sides, so it is pinning the change rather than restating it.

Also noted while there: #1127 fixes a pre-epoch throw in the same file, and the two do not overlap — that one is inside getTimestampString / createTimeString, this one is in the callers' counterpart on the other side.

@nielspardon nielspardon left a comment

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.

The TIMESTAMP_TZ diagnosis is right and the fix is real: getMaxPrecision(TIMESTAMP_TZ) is 3 while the type path uses TIMESTAMP_WITH_LOCAL_TIME_ZONE at 6, so precision_timestamp_tz<6> converted as a type but was rejected as a literal. Honouring the declared precision also fixes a real failure — SELECT * FROM (VALUES (TIMESTAMP '2024-01-01 00:00:00')) threw before this change and passes after it.

Emitting whatever precision Calcite declares is the right call, so the gap this opens is on the other side. getTimestampString and createTimeString only handle 0/3/6/9 and throw otherwise, so TIMESTAMP '2024-01-01 00:00:00.12' — which Calcite types TIMESTAMP(2) from the fractional digits written — now produces a plan Isthmus cannot read back. Nothing restricts a Substrait precision to multiples of three: algebra.proto only enumerates 0/3/6/9/12 as examples of what the number means, and this repo's own Spark path already rescales any precision 0–6 since #1128. So those two helpers should generalize rather than the forward direction narrowing back.

Worth rebasing onto main first — #1128, #1136 and #1110 landed after you branched and resolve several things this diff otherwise runs into. Also: the PR body's merge-order note and the sentence narrating the tTimestampTZ change are review notes rather than commit body.

Comment thread isthmus/src/main/java/io/substrait/isthmus/expression/LiteralConverter.java Outdated
Comment thread isthmus/src/main/java/io/substrait/isthmus/expression/LiteralConverter.java Outdated
Comment thread isthmus/src/test/java/io/substrait/isthmus/CalciteLiteralTest.java
getTimestampString and createTimeString split a value into whole seconds and a
nanosecond remainder with truncating division. Before the epoch the value is
negative, so truncation leaves a negative remainder, and both TimestampString and
TimeString reject that with a bare IllegalArgumentException: a precision_timestamp
of -500 at precision 3, which is 1969-12-31 23:59:59.5, did not convert at all.

Split with floorDiv/floorMod so the remainder is never negative, and share the
split between the two methods rather than repeating it once per supported
precision. The set of supported precisions and both failure messages are
unchanged.
Found reviewing the change above. A precision_time is a time of day, so a value
below zero or a day or more is not one. Neither is reported as such today: a
negative value reaches TimeString.fromMillisOfDay and comes back as a corrupt
time string (Invalid time format: [00:00:0/]), and a value of a day or more is
either Guava's "Hour out of range: [24]" from inside TimeString or, once the
millisecond count overflows an int, a silently wrong time - 4346734 seconds
converts to 14:22:46. A range check names the value instead, and makes the
narrowing to int provably safe.
LiteralConverter.convert already computes the Substrait type of the literal it is
handed, then ignored it for the temporal cases: TIME and TIMESTAMP were reported
at a fixed precision 6 with the value rescaled to microseconds, and
TIMESTAMP_WITH_LOCAL_TIME_ZONE shared the TIMESTAMP branch and came back as a
plain precision_timestamp. The type converter maps the same Calcite types to
precision_time<P>, precision_timestamp<P> and precision_timestamp_tz<P>, so the
two halves of the same conversion disagreed about the same literal.

Take the precision from the result type and express the value at it, and route
TIMESTAMP_WITH_LOCAL_TIME_ZONE to precisionTimestampTZ. At precision 6 the value
arithmetic is what it was; other precisions now round-trip instead of being
widened.

In the other direction, the maximum-precision check for a precision_timestamp_tz
literal looked up SqlTypeName.TIMESTAMP_TZ, which SubstraitTypeSystem does not
configure - so it fell through to Calcite's default of 3 and rejected microsecond
TZ literals, while the corresponding type conversion accepted them. It now checks
TIMESTAMP_WITH_LOCAL_TIME_ZONE, the name everything else TZ-related uses, and
reports precision_timestamp_tz rather than precision_timestamp when it throws.

BREAKING CHANGE: a Calcite TIME or TIMESTAMP literal now converts to a Substrait
literal carrying its own precision rather than always precision 6, so plans
Isthmus produces from SQL such as TIMESTAMP '2024-01-01 00:00:00' now declare
precision_timestamp<0> with a value in seconds instead of precision_timestamp<6>
with a value in microseconds. A TIMESTAMP WITH LOCAL TIME ZONE literal converts
to precision_timestamp_tz instead of precision_timestamp.
Replaces the hand-rolled power-of-ten loop, matching what the interval rescale in
the sibling change uses, so the two do not drift.

Also records why narrowing a pre-epoch timestamp floors rather than truncating
towards the epoch: the value is an instant, not a duration, so 1969-12-31
23:59:59.5 at second precision is 23:59:59. The interval conversion narrows in the
other direction for the opposite reason.
The breaking-change note is about the plans Isthmus produces from SQL, and nothing
asserted that half: a literal written without a fractional part is TIMESTAMP(0) on
the Calcite side, so the plan now declares precision_timestamp<0> with a value in
seconds rather than precision_timestamp<6> with a value in microseconds.
…hird one

Review follow-up. Emitting the precision Calcite declares only works if the
reverse direction takes it back, and it did not: getTimestampString and
createTimeString handled 0, 3, 6 and 9 and threw for everything else, so
TIMESTAMP '2024-01-01 00:00:00.12' -- precision 2, from the fractional digits
written -- produced a plan Isthmus could not read. algebra.proto documents what
0, 3, 6, 9 and 12 mean and leaves the field an unbounded int32, so the unit is
now 10^-precision for any precision a TimeString or TimestampString carries.

Two more from the same review.

A temporal type built from a Java class rather than a SQL type name carries
PRECISION_NOT_SPECIFIED, and nothing read it as anything: a java.sql.Time field
of a reflective schema became precision_time<-1> with the value divided by 10^10,
and the timestamp side failed inside Guava. Both the type and the literal now
read the sentinel as precision 0, the way Calcite's own RexBuilder.clean does.

The precision check was written out six times, identical apart from a SqlTypeName
and a hand-typed noun -- which is how the TZ literal ended up checked against the
wrong name with the wrong noun in its message. It is now
SubstraitTypeSystem.requireSupportedPrecision.
@alexandrefimov
alexandrefimov force-pushed the issue-1113-temporal-literals branch from cf8a6e5 to 7fa13d6 Compare August 21, 2026 17:34
@alexandrefimov

Copy link
Copy Markdown
Contributor Author

Rebased onto #1127 as you suggested, so the first two commits here are that PR and the generalization sits on top of its floorDiv/floorMod split.

The reverse direction takes any precision 0-9 now, PRECISION_NOT_SPECIFIED reads as 0 on both the type and the literal, and the six-times-over guard is one SubstraitTypeSystem.requireSupportedPrecision. The description lost the merge-order note and the tTimestampTZ narration.

One correction to something I then wrote myself: algebra.proto and type.proto say what 0, 3, 6, 9 and 12 mean and leave the field an unbounded int32 — they do not say those are examples, that is a reading, so the comment and the commit body now claim only the former.

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.

isthmus: precision_timestamp_tz literals cap at precision 3 converting to Calcite and come back as plain precision_timestamp

2 participants