Repository: moment/luxon
Pull request #1574 (2024-01-22 15:58:19)

Perf: Add benchmark for DateTime.local with zone
_This is part of a series of PRs based on performance work we have done to
improve a use-case involving parsing/formatting hundreds of thousands of dates
where luxon was the bottleneck._

We found that time zone handling took up a lot of time. This adds a benchmark around this for `DateTime.local` (which uses `quickDT` and is the fastest path for creating DateTimes from date components. This also renames the existing `DateTime.local` benchmark to be `DateTIme.now` as it calls `DateTime.now` rather than `local`

```
DateTime.now x 3,076,386 ops/sec ±0.16% (98 runs sampled)
DateTime.fromObject with locale x 1,112,953 ops/sec ±0.08% (99 runs sampled)
DateTime.local with numbers x 844,898 ops/sec ±0.15% (100 runs sampled)
DateTime.local with numbers and zone x 50,913 ops/sec ±0.18% (97 runs sampled)
DateTime.fromISO x 329,144 ops/sec ±0.32% (98 runs sampled)
DateTime.fromSQL x 460,943 ops/sec ±0.16% (101 runs sampled)
DateTime.fromFormat x 60,666 ops/sec ±0.17% (100 runs sampled)
DateTime.fromFormat with zone x 26,687 ops/sec ±0.18% (100 runs sampled)
DateTime#setZone x 175,791 ops/sec ±0.29% (96 runs sampled)
DateTime#toFormat x 1,029,132 ops/sec ±0.15% (99 runs sampled)
DateTime#toFormat with macro x 450,668 ops/sec ±0.32% (95 runs sampled)
DateTime#toFormat with macro no cache x 12,197 ops/sec ±9.62% (80 runs sampled)
DateTime#add x 351,727 ops/sec ±3.49% (100 runs sampled)
DateTime#toISO x 6,169,310 ops/sec ±0.15% (100 runs sampled)
DateTime#toLocaleString x 517,180 ops/sec ±0.36% (98 runs sampled)
DateTime#toLocaleString in utc x 236,294 ops/sec ±0.34% (99 runs sampled)
DateTime#toRelativeCalendar x 14,076 ops/sec ±0.32% (95 runs sampled)
Fastest is DateTime#toISO
Info.months with existing locale x 52,033,486 ops/sec ±0.10% (100 runs sampled)
Info.months x 11,068,821 ops/sec ±0.47% (95 runs sampled)
Fastest is Info.months with existing locale
Info.monthsFormat with existing locale x 42,476,619 ops/sec ±1.00% (95 runs sampled)
Info.monthsFormat x 9,895,981 ops/sec ±0.26% (98 runs sampled)
Fastest is Info.monthsFormat with existing locale
Info.weekdays with existing locale x 43,636,745 ops/sec ±1.08% (99 runs sampled)
Info.weekdays x 12,718,724 ops/sec ±0.21% (98 runs sampled)
Fastest is Info.weekdays with existing locale
Info.weekdaysFormat with existing locale x 37,275,570 ops/sec ±0.67% (98 runs sampled)
Info.weekdaysFormat x 13,117,957 ops/sec ±0.62% (98 runs sampled)
Fastest is Info.weekdaysFormat with existing locale
```

linux-foundation-easycla[bot]:  <a href="https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1574/#/?version=2"><img src="https://s3.amazonaws.com/cla-project-logo-prod/cla-not-signed.svg" alt="CLA Not Signed" align="left" height="28" width="328"></a><br/><br /><ul><li><a href='https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1574/#/?version=2' target='_blank'>:x:</a> - login: @schleyfox / name: Ben Hughes . The commit (def2eac1eb90efb933f1db20d1b7039f1c668ff2) is not authorized under a signed CLA. <a href='https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1574/#/?version=2' target='_blank'>Please click here to be authorized</a>. For further assistance with EasyCLA, <a href='https://jira.linuxfoundation.org/servicedesk/customer/portal/4' target='_blank'>please submit a support request ticket</a>.</li></ul>

Patch:
diff --git a/benchmarks/datetime.js b/benchmarks/datetime.js
index 39d6bf07..f4dffda7 100644
--- a/benchmarks/datetime.js
+++ b/benchmarks/datetime.js
@@ -8,8 +8,10 @@ function runDateTimeSuite() {
 
     const dt = DateTime.now();
 
+    const formatParser = DateTime.buildFormatParser("yyyy/MM/dd HH:mm:ss.SSS");
+
     suite
-      .add("DateTime.local", () => {
+      .add("DateTime.now", () => {
         DateTime.now();
       })
       .add("DateTime.fromObject with locale", () => {
@@ -18,6 +20,9 @@ function runDateTimeSuite() {
       .add("DateTime.local with numbers", () => {
         DateTime.local(2017, 5, 15);
       })
+      .add("DateTime.local with numbers and zone", () => {
+        DateTime.local(2017, 5, 15, 11, 7, 35, { zone: "America/New_York" });
+      })
       .add("DateTime.fromISO", () => {
         DateTime.fromISO("1982-05-25T09:10:11.445Z");
       })
@@ -32,6 +37,14 @@ function runDateTimeSuite() {
           zone: "America/Los_Angeles",
         });
       })
+      .add("DateTime.fromFormatParser", () => {
+        DateTime.fromFormatParser("1982/05/25 09:10:11.445", formatParser);
+      })
+      .add("DateTime.fromFormatParser with zone", () => {
+        DateTime.fromFormatParser("1982/05/25 09:10:11.445", formatParser, {
+          zone: "America/Los_Angeles",
+        });
+      })
       .add("DateTime#setZone", () => {
         dt.setZone("America/Los_Angeles");
       })
diff --git a/src/datetime.js b/src/datetime.js
index 9472cb53..5314a9b3 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -28,6 +28,7 @@ import {
   explainFromTokens,
   formatOptsToTokens,
   expandMacroTokens,
+  TokenParser,
 } from "./impl/tokenParser.js";
 import {
   gregorianToWeek,
@@ -373,8 +374,12 @@ function normalizeUnitWithLocalWeeks(unit) {
 // but doesn't do any validation, makes a bunch of assumptions about what units
 // are present, and so on.
 function quickDT(obj, opts) {
-  const zone = normalizeZone(opts.zone, Settings.defaultZone),
-    loc = Locale.fromObject(opts),
+  const zone = normalizeZone(opts.zone, Settings.defaultZone);
+  if (!zone.isValid) {
+    return DateTime.invalid(unsupportedZone(zone));
+  }
+
+  const loc = Locale.fromObject(opts),
     tsNow = Settings.now();
 
   let ts, o;
@@ -487,7 +492,9 @@ export default class DateTime {
       if (unchanged) {
         [c, o] = [config.old.c, config.old.o];
       } else {
-        const ot = zone.offset(this.ts);
+        // If an offset has been passed and we have not been called from
+        // clone(), we can trust it and avoid the offset calculation.
+        const ot = isNumber(config.o) && !config.old ? config.o : zone.offset(this.ts);
         c = tsToObj(this.ts, ot);
         invalid = Number.isNaN(c.year) ? new Invalid("invalid input") : null;
         c = invalid ? null : c;
@@ -2229,6 +2236,74 @@ export default class DateTime {
     return DateTime.fromFormatExplain(text, fmt, options);
   }
 
+  /**
+   * Build a parser for `fmt` using the given locale. This parser can be passed
+   * to {@link DateTime.fromFormatParser} to a parse a date in this format. This
+   * can be used to optimize cases where many dates need to be parsed in a
+   * specific format.
+   *
+   * @param {String} fmt - the format the string is expected to be in (see
+   * description)
+   * @param {Object} options - options used to set locale and numberingSystem
+   * for parser
+   * @returns {TokenParser} - opaque object to be used
+   */
+  static buildFormatParser(fmt, options = {}) {
+    const { locale = null, numberingSystem = null } = options,
+      localeToUse = Locale.fromOpts({
+        locale,
+        numberingSystem,
+        defaultToEN: true,
+      });
+    return new TokenParser(localeToUse, fmt);
+  }
+
+  /**
+   * Create a DateTime from an input string and format parser.
+   *
+   * The format parser must have been created with the same locale as this call.
+   *
+   * @param {String} text - the string to parse
+   * @param {TokenParser} formatParser - parser from {@link DateTime.buildFormatParser}
+   * @param {Object} opts - options taken by fromFormat()
+   * @returns {DateTime}
+   */
+  static fromFormatParser(text, formatParser, opts = {}) {
+    if (isUndefined(text) || isUndefined(formatParser)) {
+      throw new InvalidArgumentError(
+        "fromFormatParser requires an input string and a format parser"
+      );
+    }
+    const { locale = null, numberingSystem = null } = opts,
+      localeToUse = Locale.fromOpts({
+        locale,
+        numberingSystem,
+        defaultToEN: true,
+      });
+
+    if (!localeToUse.equals(formatParser.locale)) {
+      throw new InvalidArgumentError(
+        `fromFormatParser called with a locale of ${localeToUse}, ` +
+          `but the format parser was created for ${formatParser.locale}`
+      );
+    }
+
+    const { result, zone, specificOffset, invalidReason } = formatParser.explainFromTokens(text);
+
+    if (invalidReason) {
+      return DateTime.invalid(invalidReason);
+    } else {
+      return parseDataToDateTime(
+        result,
+        zone,
+        opts,
+        `format ${formatParser.format}`,
+        text,
+        specificOffset
+      );
+    }
+  }
+
   // FORMAT PRESETS
 
   /**
diff --git a/src/impl/digits.js b/src/impl/digits.js
index 6ac1bae0..c8c68af0 100644
--- a/src/impl/digits.js
+++ b/src/impl/digits.js
@@ -70,6 +70,21 @@ export function parseDigits(str) {
   }
 }
 
+// cache of {numberingSystem: {append: regex}}
+let digitRegexCache = {};
+export function resetDigitRegexCache() {
+  digitRegexCache = {};
+}
+
 export function digitRegex({ numberingSystem }, append = "") {
-  return new RegExp(`${numberingSystems[numberingSystem || "latn"]}${append}`);
+  const ns = numberingSystem || "latn";
+
+  if (!digitRegexCache[ns]) {
+    digitRegexCache[ns] = {};
+  }
+  if (!digitRegexCache[ns][append]) {
+    digitRegexCache[ns][append] = new RegExp(`${numberingSystems[ns]}${append}`);
+  }
+
+  return digitRegexCache[ns][append];
 }
diff --git a/src/impl/locale.js b/src/impl/locale.js
index f1caf149..cd55b3bf 100644
--- a/src/impl/locale.js
+++ b/src/impl/locale.js
@@ -539,4 +539,8 @@ export default class Locale {
       this.outputCalendar === other.outputCalendar
     );
   }
+
+  toString() {
+    return `Locale(${this.locale}, ${this.numberingSystem}, ${this.outputCalendar})`;
+  }
 }
diff --git a/src/impl/tokenParser.js b/src/impl/tokenParser.js
index 8dd38f37..48a7595e 100644
--- a/src/impl/tokenParser.js
+++ b/src/impl/tokenParser.js
@@ -432,27 +432,59 @@ export function expandMacroTokens(tokens, locale) {
  * @private
  */
 
-export function explainFromTokens(locale, input, format) {
-  const tokens = expandMacroTokens(Formatter.parseFormat(format), locale),
-    units = tokens.map((t) => unitForToken(t, locale)),
-    disqualifyingUnit = units.find((t) => t.invalidReason);
+export class TokenParser {
+  constructor(locale, format) {
+    this.locale = locale;
+    this.format = format;
+    this.tokens = expandMacroTokens(Formatter.parseFormat(format), locale);
+    this.units = this.tokens.map((t) => unitForToken(t, locale));
+    this.disqualifyingUnit = this.units.find((t) => t.invalidReason);
+
+    if (!this.disqualifyingUnit) {
+      const [regexString, handlers] = buildRegex(this.units);
+      this.regex = RegExp(regexString, "i");
+      this.handlers = handlers;
+    }
+  }
 
-  if (disqualifyingUnit) {
-    return { input, tokens, invalidReason: disqualifyingUnit.invalidReason };
-  } else {
-    const [regexString, handlers] = buildRegex(units),
-      regex = RegExp(regexString, "i"),
-      [rawMatches, matches] = match(input, regex, handlers),
-      [result, zone, specificOffset] = matches
-        ? dateTimeFromMatches(matches)
-        : [null, null, undefined];
-    if (hasOwnProperty(matches, "a") && hasOwnProperty(matches, "H")) {
-      throw new ConflictingSpecificationError(
-        "Can't include meridiem when specifying 24-hour format"
-      );
+  explainFromTokens(input) {
+    if (!this.isValid) {
+      return { input, tokens: this.tokens, invalidReason: this.invalidReason };
+    } else {
+      const [rawMatches, matches] = match(input, this.regex, this.handlers),
+        [result, zone, specificOffset] = matches
+          ? dateTimeFromMatches(matches)
+          : [null, null, undefined];
+      if (hasOwnProperty(matches, "a") && hasOwnProperty(matches, "H")) {
+        throw new ConflictingSpecificationError(
+          "Can't include meridiem when specifying 24-hour format"
+        );
+      }
+      return {
+        input,
+        tokens: this.tokens,
+        regex: this.regex,
+        rawMatches,
+        matches,
+        result,
+        zone,
+        specificOffset,
+      };
     }
-    return { input, tokens, regex, rawMatches, matches, result, zone, specificOffset };
   }
+
+  get isValid() {
+    return !this.disqualifyingUnit;
+  }
+
+  get invalidReason() {
+    return this.disqualifyingUnit ? this.disqualifyingUnit.invalidReason : null;
+  }
+}
+
+export function explainFromTokens(locale, input, format) {
+  const parser = new TokenParser(locale, format);
+  return parser.explainFromTokens(input);
 }
 
 export function parseFromTokens(locale, input, format) {
diff --git a/src/settings.js b/src/settings.js
index 462e71e6..f109708d 100644
--- a/src/settings.js
+++ b/src/settings.js
@@ -4,6 +4,7 @@ import Locale from "./impl/locale.js";
 
 import { normalizeZone } from "./impl/zoneUtil.js";
 import { validateWeekSettings } from "./impl/util.js";
+import { resetDigitRegexCache } from "./impl/digits.js";
 
 let now = () => Date.now(),
   defaultZone = "system",
@@ -171,5 +172,6 @@ export default class Settings {
   static resetCaches() {
     Locale.resetCache();
     IANAZone.resetCache();
+    resetDigitRegexCache();
   }
 }


Test patch:
diff --git a/test/datetime/invalid.test.js b/test/datetime/invalid.test.js
index d13fb864..2fed5bbd 100644
--- a/test/datetime/invalid.test.js
+++ b/test/datetime/invalid.test.js
@@ -5,7 +5,11 @@ import { DateTime, Settings } from "../../src/luxon";
 const organic1 = DateTime.utc(2014, 13, 33),
   // not an actual Wednesday
   organic2 = DateTime.fromObject({ weekday: 3, year: 1982, month: 5, day: 25 }, { zone: "UTC" }),
-  organic3 = DateTime.fromObject({ year: 1982, month: 5, day: 25, hour: 27 });
+  organic3 = DateTime.fromObject({ year: 1982, month: 5, day: 25, hour: 27 }),
+  organic4 = DateTime.fromObject(
+    { year: 1982, month: 5, day: 25, hour: 2 },
+    { zone: "America/Lasers" }
+  );
 
 test("Explicitly invalid dates are invalid", () => {
   const dt = DateTime.invalid("just because", "seriously, just because");
@@ -22,14 +26,28 @@ test("Invalid creations are invalid", () => {
 
 test("invalid zones result in invalid dates", () => {
   expect(DateTime.now().setZone("America/Lasers").isValid).toBe(false);
+  expect(DateTime.now().setZone("America/Lasers").invalidReason).toBe("unsupported zone");
+
   expect(DateTime.local({ zone: "America/Lasers" }).isValid).toBe(false);
+  expect(DateTime.local({ zone: "America/Lasers" }).invalidReason).toBe("unsupported zone");
+
+  expect(DateTime.local(1982, { zone: "America/Lasers" }).isValid).toBe(false);
+  expect(DateTime.local(1982, { zone: "America/Lasers" }).invalidReason).toBe("unsupported zone");
+
   expect(DateTime.fromJSDate(new Date(), { zone: "America/Lasers" }).isValid).toBe(false);
+  expect(DateTime.fromJSDate(new Date(), { zone: "America/Lasers" }).invalidReason).toBe(
+    "unsupported zone"
+  );
+
+  expect(DateTime.fromMillis(0, { zone: "America/Lasers" }).isValid).toBe(false);
+  expect(DateTime.fromMillis(0, { zone: "America/Lasers" }).invalidReason).toBe("unsupported zone");
 });
 
 test("Invalid DateTimes tell you why", () => {
   expect(organic1.invalidReason).toBe("unit out of range");
   expect(organic2.invalidReason).toBe("mismatched weekday");
   expect(organic3.invalidReason).toBe("unit out of range");
+  expect(organic4.invalidReason).toBe("unsupported zone");
 });
 
 test("Invalid DateTimes can provide an extended explanation", () => {
diff --git a/test/datetime/proto.test.js b/test/datetime/proto.test.js
index bbe132dc..71fba90f 100644
--- a/test/datetime/proto.test.js
+++ b/test/datetime/proto.test.js
@@ -4,6 +4,6 @@ import { DateTime } from "../../src/luxon";
 test("DateTime prototype properties should not throw when accessed", () => {
   const d = DateTime.now();
   expect(() =>
-    Object.getOwnPropertyNames(d.__proto__).forEach((name) => d.__proto__[name])
+    Object.getOwnPropertyNames(Object.getPrototypeOf(d)).forEach((name) => Object.getPrototypeOf(d)[name])
   ).not.toThrow();
 });
diff --git a/test/datetime/tokenParse.test.js b/test/datetime/tokenParse.test.js
index 4025821d..8b5c6a8d 100644
--- a/test/datetime/tokenParse.test.js
+++ b/test/datetime/tokenParse.test.js
@@ -1224,3 +1224,28 @@ test("DateTime.expandFormat respects the hour cycle when forced by the macro tok
   const format = DateTime.expandFormat("T", { locale: "en-US" });
   expect(format).toBe("H:m");
 });
+
+//------
+// .fromFormatParser
+//-------
+
+test("DateTime.fromFormatParser behaves equivalently to DateTime.fromFormat", () => {
+  const dateTimeStr = "1982/05/25 09:10:11.445";
+  const format = "yyyy/MM/dd HH:mm:ss.SSS";
+  const formatParser = DateTime.buildFormatParser(format);
+  const ff1 = DateTime.fromFormat(dateTimeStr, format),
+    ffP1 = DateTime.fromFormatParser(dateTimeStr, formatParser);
+
+  expect(ffP1).toEqual(ff1);
+  expect(ffP1.isValid).toBe(true);
+});
+
+test("DateTime.fromFormatParser throws error when used with a different locale than it was created with", () => {
+  const format = "yyyy/MM/dd HH:mm:ss.SSS";
+  const formatParser = DateTime.buildFormatParser(format, { locale: "es-ES" });
+  expect(() =>
+    DateTime.fromFormatParser("1982/05/25 09:10:11.445", formatParser, { locale: "es-MX" })
+  ).toThrowError(
+    "fromFormatParser called with a locale of Locale(es-MX, null, null), but the format parser was created for Locale(es-ES, null, null)"
+  );
+});
diff --git a/test/duration/proto.test.js b/test/duration/proto.test.js
index aba74c4f..40132ac9 100644
--- a/test/duration/proto.test.js
+++ b/test/duration/proto.test.js
@@ -3,6 +3,6 @@ import { Duration } from "../../src/luxon";
 test("Duration prototype properties should not throw when addressed", () => {
   const d = Duration.fromObject({ hours: 1 });
   expect(() =>
-    Object.getOwnPropertyNames(d.__proto__).forEach((name) => d.__proto__[name])
+    Object.getOwnPropertyNames(Object.getPrototypeOf(d)).forEach((name) => Object.getPrototypeOf(d)[name])
   ).not.toThrow();
 });
diff --git a/test/interval/proto.test.js b/test/interval/proto.test.js
index bcc3e9b3..ddf873ac 100644
--- a/test/interval/proto.test.js
+++ b/test/interval/proto.test.js
@@ -4,6 +4,6 @@ import { DateTime } from "../../src/luxon";
 test("Interval prototype properties should not throw when addressed", () => {
   const i = DateTime.fromISO("2018-01-01").until(DateTime.fromISO("2018-01-02"));
   expect(() =>
-    Object.getOwnPropertyNames(i.__proto__).forEach((name) => i.__proto__[name])
+    Object.getOwnPropertyNames(Object.getPrototypeOf(i)).forEach((name) => Object.getPrototypeOf(i)[name])
   ).not.toThrow();
 });