Skip to content

Commit

Permalink
Editorial: Simplify date/time range validation
Browse files Browse the repository at this point in the history
This commit refactors several operations related to range validation of
Temporal.PlainDateTime and Temporal.Instant.

* Makes polyfill GetUTCEpochNanoseconds infallible, like in the spec.
* Simplifies the polyfill's GetNamedTimeZoneOffsetNanoseconds by using
  the now-infallible GetUTCEpochNanoseconds.
* Adds a new AO GetEpochNanoseconds to DRY date/time/offset=>nanoseconds
  calculations, and changes some call sites of GetUTCEpochNanoseconds
  to use this new AO instead.
* Simplifies the polyfill's PlainDateTime range validation in
  RejectDateTimeRange.
* Clarifies the PlainDateTime docs about that type's valid range.
* Simplifies Instant parsing by using the new GetEpochNanoseconds AO.
* Removes non-parsing logic from ParseTemporalInstantString, aligning
  Instant parsing with the parsing pattern used in other Temporal
  types where parsing AOs contain only parsing without interpretation.
* Inlines the single-caller ParseTemporalInstant into ToTemporalInstant,
  matching the pattern used by ToTemporalYearMonth, ToTemporalTime, etc.
* Marks ParseDateTimeUTCOffset as infallible after parsing instant
  strings, because ParseTemporalInstantString guarantees that the
  offset is valid. Fixes #2637.
* Aligns polyfill more closely to spec for Instant parsing.
  • Loading branch information
justingrant committed Aug 19, 2023
1 parent c4f9cf7 commit 8caefac
Show file tree
Hide file tree
Showing 7 changed files with 152 additions and 128 deletions.
6 changes: 4 additions & 2 deletions docs/plaindatetime.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ Together, `isoYear`, `isoMonth`, and `isoDay` must represent a valid date in tha
> **NOTE**: Although Temporal does not deal with leap seconds, dates coming from other software may have a `second` value of 60.
> This value will cause the constructor will throw, so if you have to interoperate with times that may contain leap seconds, use `Temporal.PlainDateTime.from()` instead.
The range of allowed values for this type is exactly enough that calling `timeZone.getPlainDateTimeFor(instant)` will succeed when `timeZone` is any built-in `Temporal.TimeZone` and `instant` is any valid `Temporal.Instant`.
If the parameters passed in to this constructor form a date outside of this range, then this function will throw a `RangeError`.
The range of allowed values for this type is wider (by one nanosecond smaller than one day) on each end than the range of `Temporal.Instant`.
Because the magnitude of built-in time zones' UTC offset will always be less than 24 hours, this extra range ensures that a valid `Temporal.Instant` can always be converted to a valid `Temporal.PlainDateTime` using any built-in time zone.
Note that the reverse conversion is not guaranteed to succeed; a valid `Temporal.PlainDateTime` at the edge of its range may, for some built-in time zones, be out of range of `Temporal.Instant`.
If the parameters passed in to this constructor are out of range, then this function will throw a `RangeError`.

Usually `calendar` will be a string containing the identifier of a built-in calendar, such as `'islamic'` or `'gregory'`.
Use an object if you need to supply [custom calendar behaviour](./calendar.md#custom-calendars).
Expand Down
188 changes: 98 additions & 90 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,18 @@ const $isEnumerable = callBound('Object.prototype.propertyIsEnumerable');

const DAY_SECONDS = 86400;
const DAY_NANOS = bigInt(DAY_SECONDS).multiply(1e9);
const NS_MIN = bigInt(-DAY_SECONDS).multiply(1e17);
const NS_MAX = bigInt(DAY_SECONDS).multiply(1e17);
// Instant range is 100 million days (inclusive) before or after epoch.
const NS_MIN = DAY_NANOS.multiply(-1e8);
const NS_MAX = DAY_NANOS.multiply(1e8);
// PlainDateTime range is 24 hours wider (exclusive) than the Instant range on
// both ends, to allow for valid Instant=>PlainDateTime conversion for all
// built-in time zones (whose offsets must have a magnitude less than 24 hours).
const DATETIME_NS_MIN = NS_MIN.subtract(DAY_NANOS).add(bigInt.one);
const DATETIME_NS_MAX = NS_MAX.add(DAY_NANOS).subtract(bigInt.one);
// The pattern of leap years in the ISO 8601 calendar repeats every 400 years.
// The constant below is the number of nanoseconds in 400 years. It is used to
// avoid overflows when dealing with values at the edge legacy Date's range.
const NS_IN_400_YEAR_CYCLE = bigInt(400 * 365 + 97).multiply(DAY_NANOS);
const YEAR_MIN = -271821;
const YEAR_MAX = 275760;
const BEFORE_FIRST_DST = bigInt(-388152).multiply(1e13); // 1847-01-01T00:00:00Z
Expand Down Expand Up @@ -662,28 +672,6 @@ export function ParseTemporalDurationString(isoString) {
return { years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, nanoseconds };
}

export function ParseTemporalInstant(isoString) {
let { year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, offset, z } =
ParseTemporalInstantString(isoString);

// ParseTemporalInstantString ensures that either `z` or `offset` are non-undefined
const offsetNs = z ? 0 : ParseDateTimeUTCOffset(offset);
({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } = BalanceISODateTime(
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond - offsetNs
));
const epochNs = GetUTCEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond);
if (epochNs === null) throw new RangeError('DateTime outside of supported range');
return epochNs;
}

export function RegulateISODate(year, month, day, overflow) {
switch (overflow) {
case 'reject':
Expand Down Expand Up @@ -1279,13 +1267,32 @@ export function ToTemporalDuration(item) {
}

export function ToTemporalInstant(item) {
if (IsTemporalInstant(item)) return item;
if (IsTemporalZonedDateTime(item)) {
const TemporalInstant = GetIntrinsic('%Temporal.Instant%');
return new TemporalInstant(GetSlot(item, EPOCHNANOSECONDS));
if (Type(item === 'Object')) {
if (IsTemporalInstant(item)) return item;
if (IsTemporalZonedDateTime(item)) {
const TemporalInstant = GetIntrinsic('%Temporal.Instant%');
return new TemporalInstant(GetSlot(item, EPOCHNANOSECONDS));
}
item = ToPrimitive(item, StringCtor);
}
item = ToPrimitive(item, StringCtor);
const ns = ParseTemporalInstant(RequireString(item));
const { year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, offset, z } =
ParseTemporalInstantString(RequireString(item));

// ParseTemporalInstantString ensures that either `z` is true or or `offset` is non-undefined
const offsetNs = z ? 0 : ParseDateTimeUTCOffset(offset);
const ns = GetEpochNanoseconds(
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond,
offsetNs
);

const TemporalInstant = GetIntrinsic('%Temporal.Instant%');
return new TemporalInstant(ns);
}
Expand Down Expand Up @@ -1421,19 +1428,7 @@ export function InterpretISODateTimeOffset(
// for this timezone and date/time.
if (offsetBehaviour === 'exact' || offsetOpt === 'use') {
// Calculate the instant for the input's date/time and offset
const epochNs = GetUTCEpochNanoseconds(
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond
);
if (epochNs === null) throw new RangeError('ZonedDateTime outside of supported range');
return epochNs.minus(offsetNs);
return GetEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, offsetNs);
}

// "prefer" or "reject"
Expand Down Expand Up @@ -2309,9 +2304,12 @@ export function DisambiguatePossibleInstants(possibleInstants, timeZone, dateTim
const microsecond = GetSlot(dateTime, ISO_MICROSECOND);
const nanosecond = GetSlot(dateTime, ISO_NANOSECOND);
const utcns = GetUTCEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond);
if (utcns === null) throw new RangeError('DateTime outside of supported range');
const dayBefore = new Instant(utcns.minus(86400e9));
const dayAfter = new Instant(utcns.plus(86400e9));

// In the spec, range validation of `dayBefore` and `dayAfter` happens here.
// In the polyfill, it happens in the Instant constructor.
const dayBefore = new Instant(utcns.minus(DAY_NANOS));
const dayAfter = new Instant(utcns.plus(DAY_NANOS));

const offsetBefore = GetOffsetNanosecondsFor(timeZone, dayBefore);
const offsetAfter = GetOffsetNanosecondsFor(timeZone, dayAfter);
const nanoseconds = offsetAfter - offsetBefore;
Expand Down Expand Up @@ -2744,28 +2742,8 @@ export function GetAvailableNamedTimeZoneIdentifier(identifier) {
export function GetNamedTimeZoneOffsetNanoseconds(id, epochNanoseconds) {
const { year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } =
GetNamedTimeZoneDateTimeParts(id, epochNanoseconds);

// The pattern of leap years in the ISO 8601 calendar repeats every 400
// years. To avoid overflowing at the edges of the range, we reduce the year
// to the remainder after dividing by 400, and then add back all the
// nanoseconds from the multiples of 400 years at the end.
const reducedYear = year % 400;
const yearCycles = (year - reducedYear) / 400;
const nsIn400YearCycle = bigInt(400 * 365 + 97).multiply(DAY_NANOS);

const reducedUTC = GetUTCEpochNanoseconds(
reducedYear,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond
);
const utc = reducedUTC.plus(nsIn400YearCycle.multiply(yearCycles));
return +utc.minus(epochNanoseconds);
const utc = GetUTCEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond);
return utc.minus(epochNanoseconds).toJSNumber();
}

export function FormatOffsetTimeZoneIdentifier(offsetMinutes) {
Expand All @@ -2782,19 +2760,44 @@ export function FormatDateTimeUTCOffsetRounded(offsetNanoseconds) {
return FormatOffsetTimeZoneIdentifier(offsetNanoseconds / 60e9);
}

export function GetUTCEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond) {
export function GetEpochNanoseconds(
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond,
offsetNs
) {
let result = GetUTCEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond);
if (offsetNs) result = result.subtract(bigInt(offsetNs));
ValidateEpochNanoseconds(result);
return result;
}

function GetUTCEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond) {
// The pattern of leap years in the ISO 8601 calendar repeats every 400
// years. To avoid overflowing at the edges of the range, we reduce the year
// to the remainder after dividing by 400, and then add back all the
// nanoseconds from the multiples of 400 years at the end.
const reducedYear = year % 400;
const yearCycles = (year - reducedYear) / 400;

// Note: Date.UTC() interprets one and two-digit years as being in the
// 20th century, so don't use it
const legacyDate = new Date();
legacyDate.setUTCHours(hour, minute, second, millisecond);
legacyDate.setUTCFullYear(year, month - 1, day);
legacyDate.setUTCFullYear(reducedYear, month - 1, day);
const ms = legacyDate.getTime();
if (NumberIsNaN(ms)) return null;
let ns = bigInt(ms).multiply(1e6);
ns = ns.plus(bigInt(microsecond).multiply(1e3));
ns = ns.plus(bigInt(nanosecond));
if (ns.lesser(NS_MIN) || ns.greater(NS_MAX)) return null;
return ns;

const result = ns.plus(NS_IN_400_YEAR_CYCLE.multiply(bigInt(yearCycles)));
return result;
}

export function GetISOPartsFromEpoch(epochNanoseconds) {
Expand Down Expand Up @@ -2927,6 +2930,11 @@ export function GetFormatterParts(timeZone, epochMilliseconds) {
};
}

// The goal of this function is to find the exact time(s) that correspond to a
// calendar date and clock time in a particular time zone. Normally there will
// be only one match. But for repeated clock times after backwards transitions
// (like when DST ends) there may be two matches. And for skipped clock times
// after forward transitions, there will be no matches.
export function GetNamedTimeZoneEpochNanoseconds(
id,
year,
Expand All @@ -2939,15 +2947,17 @@ export function GetNamedTimeZoneEpochNanoseconds(
microsecond,
nanosecond
) {
// Get the offset of one day before and after the requested calendar date and
// clock time, avoiding overflows if near the edge of the Instant range.
let ns = GetUTCEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond);
if (ns === null) throw new RangeError('DateTime outside of supported range');
let nsEarlier = ns.minus(DAY_NANOS);
if (nsEarlier.lesser(NS_MIN)) nsEarlier = ns;
let nsLater = ns.plus(DAY_NANOS);
if (nsLater.greater(NS_MAX)) nsLater = ns;
const earliest = GetNamedTimeZoneOffsetNanoseconds(id, nsEarlier);
const latest = GetNamedTimeZoneOffsetNanoseconds(id, nsLater);
const found = earliest === latest ? [earliest] : [earliest, latest];
const earlierOffsetNs = GetNamedTimeZoneOffsetNanoseconds(id, nsEarlier);
const laterOffsetNs = GetNamedTimeZoneOffsetNanoseconds(id, nsLater);

const found = earlierOffsetNs === laterOffsetNs ? [earlierOffsetNs] : [earlierOffsetNs, laterOffsetNs];
return found
.map((offsetNanoseconds) => {
const epochNanoseconds = bigInt(ns).minus(offsetNanoseconds);
Expand Down Expand Up @@ -3738,23 +3748,21 @@ export function RejectDateTime(year, month, day, hour, minute, second, milliseco
}

export function RejectDateTimeRange(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond) {
RejectToRange(year, YEAR_MIN, YEAR_MAX);
// Reject any DateTime 24 hours or more outside the Instant range
if (
(year === YEAR_MIN &&
null ==
GetUTCEpochNanoseconds(year, month, day + 1, hour, minute, second, millisecond, microsecond, nanosecond - 1)) ||
(year === YEAR_MAX &&
null ==
GetUTCEpochNanoseconds(year, month, day - 1, hour, minute, second, millisecond, microsecond, nanosecond + 1))
) {
throw new RangeError('DateTime outside of supported range');
const ns = GetUTCEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond);
if (ns.lesser(DATETIME_NS_MIN) || ns.greater(DATETIME_NS_MAX)) {
// Because PlainDateTime's range is wider than Instant's range, the line
// below will always throw. Calling `ValidateEpochNanoseconds` avoids
// repeating the same error message twice.
ValidateEpochNanoseconds(ns);
}
}

// In the spec, IsValidEpochNanoseconds returns a boolean and call sites are
// responsible for throwing. In the polyfill, ValidateEpochNanoseconds takes its
// place so that we can DRY the throwing code.
export function ValidateEpochNanoseconds(epochNanoseconds) {
if (epochNanoseconds.lesser(NS_MIN) || epochNanoseconds.greater(NS_MAX)) {
throw new RangeError('Instant outside of supported range');
throw new RangeError('date/time value is outside of supported range');
}
}

Expand Down Expand Up @@ -5019,7 +5027,7 @@ export function RoundNumberToIncrement(quantity, increment, mode) {
}

export function RoundInstant(epochNs, increment, unit, roundingMode) {
let { remainder } = NonNegativeBigIntDivmod(epochNs, 86400e9);
let { remainder } = NonNegativeBigIntDivmod(epochNs, DAY_NANOS);
const wholeDays = epochNs.minus(remainder);
const roundedRemainder = RoundNumberToIncrement(remainder, nsPerTimeUnit[unit] * increment, roundingMode);
return wholeDays.plus(roundedRemainder);
Expand Down
8 changes: 4 additions & 4 deletions polyfill/lib/timezone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export class TimeZone {

const offsetMinutes = ES.ParseTimeZoneIdentifier(id).offsetMinutes;
if (offsetMinutes !== undefined) {
const epochNs = ES.GetUTCEpochNanoseconds(
const epochNs = ES.GetEpochNanoseconds(
GetSlot(dateTime, ISO_YEAR),
GetSlot(dateTime, ISO_MONTH),
GetSlot(dateTime, ISO_DAY),
Expand All @@ -96,10 +96,10 @@ export class TimeZone {
GetSlot(dateTime, ISO_SECOND),
GetSlot(dateTime, ISO_MILLISECOND),
GetSlot(dateTime, ISO_MICROSECOND),
GetSlot(dateTime, ISO_NANOSECOND)
GetSlot(dateTime, ISO_NANOSECOND),
offsetMinutes * 60e9
);
if (epochNs === null) throw new RangeError('DateTime outside of supported range');
return [new Instant(epochNs.minus(offsetMinutes * 60e9))];
return [new Instant(epochNs)];
}

const possibleEpochNs = ES.GetNamedTimeZoneEpochNanoseconds(
Expand Down
28 changes: 13 additions & 15 deletions spec/abstractops.html
Original file line number Diff line number Diff line change
Expand Up @@ -1452,7 +1452,7 @@ <h1>
<h1>
ParseTemporalInstantString (
_isoString_: a String,
)
): either a normal completion containing a Record, or an abrupt completion
</h1>
<dl class="header">
<dt>description</dt>
Expand All @@ -1461,21 +1461,19 @@ <h1>
<emu-alg>
1. If ParseText(StringToCodePoints(_isoString_), |TemporalInstantString|) is a List of errors, throw a *RangeError* exception.
1. Let _result_ be ? ParseISODateTime(_isoString_).
1. Let _offsetString_ be _result_.[[TimeZone]].[[OffsetString]].
1. If _result_.[[TimeZone]].[[Z]] is *true*, then
1. Set _offsetString_ to *"+00:00"*.
1. Assert: _offsetString_ is not *undefined*.
1. Assert: Either _result_.[[TimeZone]].[[OffsetString]] is not *undefined* or _result_.[[TimeZone]].[[Z]] is *true*, but not both.
1. Return the Record {
[[Year]]: _result_.[[Year]],
[[Month]]: _result_.[[Month]],
[[Day]]: _result_.[[Day]],
[[Hour]]: _result_.[[Hour]],
[[Minute]]: _result_.[[Minute]],
[[Second]]: _result_.[[Second]],
[[Millisecond]]: _result_.[[Millisecond]],
[[Microsecond]]: _result_.[[Microsecond]],
[[Nanosecond]]: _result_.[[Nanosecond]],
[[TimeZoneOffsetString]]: _offsetString_
[[Year]]: _result_.[[Year]],
[[Month]]: _result_.[[Month]],
[[Day]]: _result_.[[Day]],
[[Hour]]: _result_.[[Hour]],
[[Minute]]: _result_.[[Minute]],
[[Second]]: _result_.[[Second]],
[[Millisecond]]: _result_.[[Millisecond]],
[[Microsecond]]: _result_.[[Microsecond]],
[[Nanosecond]]: _result_.[[Nanosecond]],
[[OffsetString]]: _result_.[[TimeZone]].[[OffsetString]],
[[Z]]: _result_.[[TimeZone]].[[Z]]
}.
</emu-alg>
</emu-clause>
Expand Down
Loading

0 comments on commit 8caefac

Please sign in to comment.