Repository: moment/luxon
Pull request #1582 (2024-01-22 16:39:03)

Perf: Add DateTime.buildFormatParser and DateTime.fromFormatParser
_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._

This allows constructing a parser for a locale/format and reusing it when parsing dates. Without this, DateTime.fromFormat constructs a new parser on every call. When parsing large amounts of date strings, this gets rather slow.

Benchmark Comparison (`name | before | after | after/before`):
```
DateTime.fromFormat | 60,666 ±0.17% | 61,000 ±0.17% | ~
DateTime.fromFormat with zone | 26,687 ±0.18% | 26,688 ±0.21% | ~
DateTime.fromFormatParser |		| 402,199 ±0.13%
DateTime.fromFormatParser with zone |		| 45,206 ±0.22%

fromFormat vs. fromFormatParser | 60,666 ±0.17% | 402,199 ±0.13% | 6.63x
fromFormat vs fromFormatParser with zone | 26,687 ±0.18% | 45,206 | 1.69x
```

linux-foundation-easycla[bot]:  <a href="https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1582/#/?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/1582/#/?version=2' target='_blank'>:x:</a> - login: @schleyfox / name: Ben Hughes . The commit (b56224e0a7f5cfa402750cfd3a96e911f8230157) is not authorized under a signed CLA. <a href='https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1582/#/?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>
schleyfox: /easycla

Patch:
diff --git a/benchmarks/datetime.js b/benchmarks/datetime.js
index 39d6bf07..32839ad2 100644
--- a/benchmarks/datetime.js
+++ b/benchmarks/datetime.js
@@ -8,6 +8,8 @@ function runDateTimeSuite() {
 
     const dt = DateTime.now();
 
+    const formatParser = DateTime.buildFormatParser("yyyy/MM/dd HH:mm:ss.SSS");
+
     suite
       .add("DateTime.local", () => {
         DateTime.now();
@@ -32,6 +34,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..a3dde952 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;
@@ -2229,6 +2234,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();
 });