temporal: close the non-DST Temporal TCK cluster (+30)#88
Merged
Conversation
added 18 commits
May 30, 2026 10:27
`duration * n` and `duration / n` (Temporal8 [7]) returned 0 because BINARY_OP_MUL/DIV emitted a bare ` * `/` / ` and SQLite coerced the duration JSON text to 0. New `_gql_dyn_mul` / `_gql_dyn_div` UDFs mirror the existing ADD/SUB `_gql_dyn_*` dispatch: a Duration operand is scaled component-wise (months, days, seconds, nanos) by the numeric factor, cascading each larger unit's fractional remainder into the next smaller one using the duration-arithmetic conversions 1 month = 30.436875 days and 1 day = 86400 s (no cross-component normalization). The final nanosecond is snapped to the nearest integer within a small tolerance (absorbs carry FP noise) then truncated toward zero, matching Cypher (e.g. *0.5 of an odd nanosecond drops). Plain numerics keep native int/float semantics inside the helper. transform routes BINARY_OP_MUL/DIV through the helpers unless both operands are numeric literals. Rigorous full pass-set diff: zero regressions, +3. 3728 -> 3731. Unit 944/944, functional clean.
…, T-0341)
Two coupled fixes in the duration machinery (udf_helpers.c):
1. Fractional duration construction (Temporal8 [6] frac examples, Temporal1
[12], Temporal7 [6]): gql_duration_compose_func's fractional-month carry used
30.0 days/month; Cypher uses the average Gregorian month 30.436875. Also
removed the composer's day-overflow roll (seconds -> days) so durations keep
sub-day time as hours (e.g. duration({hours:25}) = PT25H, fractional durations
keep PT67H), matching emit_duration_json used by duration addition.
2. date + duration (Temporal8 [1] example 3): because the duration value is no
longer pre-normalized, apply_duration_to_temporal now rolls the duration's
WHOLE-day time component (time_ns / DAY_NS, trunc toward zero) into a pure-date
input and drops the sub-day remainder. datetime/time inputs are unchanged
(they already applied the full time component).
Rigorous full pass-set diff: zero regressions, +11. 3731 -> 3742. Unit 944/944,
functional clean.
`duration.inMonths`/`duration.inDays` compared the time-of-day on the local clock face, so a tz-offset difference between the two operands (e.g. +0200 vs +0100) spuriously dropped a whole month/day (Temporal10 [3] ex19 wanted P1Y but gave P11M; [4] ex17 wanted P337D but gave P338D). Both now compare time-of-day in UTC (time_ns_of_utc) when both operands carry a tz offset, falling back to local for tz-naive pairings. inDays additionally rewritten to compute whole days as the calendar day difference (days_from_civil, unbounded) minus a partial trailing day when the later instant's time-of-day falls short of the earlier one. Rigorous full pass-set diff: zero regressions, +2. 3742 -> 3744. Unit 944/944, functional clean. Remaining Temporal10 (DST [8], large durations [9]/[10]) deferred.
…+3 TCK, T-0341)
`date({date: other, quarter: N})` reset to the quarter's first month and day 1
(1984-07-01) instead of preserving the base's month-within-quarter and day
(expected 1984-08-11 for quarter 3 over 1984-11-11, which is Q4 month-2 day-11).
When only `quarter` is selected (no dayOfQuarter), gql_date_compose_func now
keeps month_in_quarter = ((src_month-1) %% 3) + 1 and the base/override day,
mapping to new_month = (q-1)*3 + month_in_quarter (day clamped to month length).
dayOfQuarter still selects an absolute day within the quarter.
Rigorous full pass-set diff: zero regressions, +3 (Temporal3 [1] quarter
examples). 3744 -> 3747. Unit 944/944, functional clean.
`time(<zoned datetime>)` and `time({timezone:'Europe/Stockholm'})` emitted
the full `+01:00[Europe/Stockholm]` tz, but a Cypher time/localtime value
never carries a named zone — only the numeric offset (Temporal3 [3] ex17/19
want '12:00+01:00'). datetime() must still keep the region.
The shared `_gql_time_compose` UDF (used by both time() and datetime()) gained
a 13th `drop_region` argument: time()/localtime() pass 1 (strip any [Region]
from a direct named tz or an inherited base tz), datetime() passes 0. Verified
datetime() still renders '...T12:00+01:00[Europe/Stockholm]'.
Rigorous full pass-set diff: zero regressions, +2. 3747 -> 3749. Unit 944/944,
functional clean.
Parsing a time/datetime with a zero UTC offset (e.g. '2140-00:00', '2015-07-20T21:40-00:00') rendered '21:40-00:00' instead of '21:40Z' (Temporal2 [3]/[5]). gql_normalize_time_func / gql_normalize_datetime_func now emit 'Z' when the numeric offset is 00:00 and there is no named-zone bracket. Also fixed gql_extract_tz_func: its time-only detection required length >= 8 (HH:MM:SS), so a 'Z' after HH:MM (e.g. '21:40Z', length 6) wasn't seen and the time() transform appended a second 'Z' (=> '21:40ZZ'). Relaxed to length >= 5. Rigorous full pass-set diff: zero regressions, +2. 3749 -> 3751. Unit 944/944, functional clean.
`time({... timezone: '+02:05:00'})` rendered '+02:05:00'; Cypher drops a zero
seconds suffix from the offset ('+02:05') but keeps non-zero seconds
('+02:05:59', '-02:05:07') (Temporal1 [13]). New `_gql_fmt_offset` UDF
normalizes a numeric offset string; the direct time()/datetime() construction
path wraps its '+/-' timezone with it (named zones and 'Z' pass through).
Rigorous full pass-set diff: zero regressions, +1. 3751 -> 3752. Unit 944/944,
functional clean.
…K, T-0341) `date(localdatetime(...))` and `date(datetime(...))` returned null because gql_normalize_date_func rejected any string longer than 10 chars — a datetime like '1984-11-11T12:31:14' (len 19) fell through every length branch to null (Temporal3 [1] ex8/15). It now strips the time portion at the 'T' first, so the date part parses normally. Rigorous full pass-set diff: zero regressions, +2. 3752 -> 3754. Unit 944/944, functional clean.
duration('P0.75M') gave P22DT12H — the ISO duration parser used 30.0 days/month
for the fractional-month carry; Cypher uses 30.436875 (avg Gregorian month), so
0.75M = 22D + 0.8276D = P22DT19H51M49.5S (Temporal2 [7] ex4).
Rigorous full pass-set diff: zero regressions, +1. 3754 -> 3755. Unit 944/944,
functional clean. (The alternate ISO form 'P2012-02-02T...' — Temporal2 [7] ex8
— remains; needs a separate date-time duration parse path.)
…-0341)
`time({hour:10,timezone:'+01:00'}) > time({hour:9,minute:35,timezone:'+00:00'})`
returned true (Temporal7 [3]) — _gql_order_cmp compared the two time strings
lexically ('10:00+01:00' > '09:35...') instead of by UTC instant (09:00 UTC <
09:35 UTC).
gql_order_cmp_func now detects temporal-looking text (time 'HH:MM…' or
date/datetime 'YYYY-MM-DD…') on both sides and compares via parse_temporal_ns
(UTC epoch nanoseconds); other text pairs keep the lexical strcmp. Rigorous full
pass-set diff: zero regressions (no string-comparison scenario affected), +1.
3755 -> 3756. Unit 944/944, functional clean.
`datetime.timezone` returned the numeric offset ('+01:00') instead of the
named zone ('Europe/Stockholm') when the value carries one (Temporal5 [6]).
gql_temporal_field_func now returns the bracketed zone name for `.timezone`
when present, falling back to the offset form; `.offset` is unchanged.
Rigorous full pass-set diff: zero regressions, +1. 3756 -> 3757. Unit 944/944,
functional clean.
…-0341)
duration('P2012-02-02T14:37:21.545') returned PT0S — the parser only handled
the unit-letter form (P2012Y2M2D...). Added detection of the alternate ISO
form P[YYYY]-[MM]-[DD]T[hh]:[mm]:[ss] (date-style dashes after 'P') and parse
its components directly (Temporal2 [7] ex8).
Rigorous full pass-set diff: zero regressions, +1. 3757 -> 3758. Unit 944/944,
functional clean.
…-bearing, deferred)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the entire non-DST Temporal TCK cluster — 3728 → 3758 (+30), zero regressions. Each fix verified with a rigorous full pass-set diff; unit 944/944; functional clean throughout. Part of initiative GQLITE-I-0049, task GQLITE-T-0341.
Fixes (all duration/date/time semantics)
* n// ncomponent scaling (+3); fractional construction + date arithmetic with 1 month = 30.436875 days (+11); ISO fractional-month cascade (+1); alternate ISO formP<date>T<time>(+1).inMonths/inDaystz normalization (UTC instant) (+2).date()quarter preserves month-of-quarter + day (+3);time()drops named-zone[Region](+2).Z(+2); offset zero-seconds drop+02:05:00→+02:05(+1);date(datetime)extracts the date (+2).</>compares UTC instants (+1);.timezoneaccessor returns the zone name (+1).Temporal8 went 27/27. Of the original ~42 Temporal failures, only the 12 DST scenarios remain.
DST is deliberately out of scope (documented in GQLITE-T-0341)
The remaining 12 (Temporal10 [8], Temporal3 [10], Temporal2 [6]) require an embedded IANA timezone database, not a rule-based approximation:
1818-07-21 Stockholm = +00:53:28(Local Mean Time),1984-10-11 Stockholm = +01:00(pre-1996 EU DST ended the last Sunday of September).Tracked as a separate large, data-heavy follow-up.