Repository: moment/luxon
Pull request #753 (2020-08-13 23:17:57)

Support nullOnInvalid option in fromObject() constructor


Patch:
diff --git a/src/datetime.js b/src/datetime.js
index f64dea2d..feb4a4bd 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -608,10 +608,17 @@ export default class DateTime {
     obj = obj || {};
     const zoneToUse = normalizeZone(opts.zone, Settings.defaultZone);
 
-    const tsNow = Settings.now(),
-      offsetProvis = zoneToUse.offset(tsNow),
-      normalized = normalizeObject(obj, normalizeUnit),
-      containsOrdinal = !isUndefined(normalized.ordinal),
+    const tsNow = Settings.now();
+    let normalized, offsetProvis;
+    try {
+      normalized = normalizeObject(obj, normalizeUnit);
+      offsetProvis = zoneToUse.offset(tsNow);
+    } catch (error) {
+      if (opts.nullOnInvalid) return null;
+      throw error;
+    }
+
+    const containsOrdinal = !isUndefined(normalized.ordinal),
       containsGregorYear = !isUndefined(normalized.year),
       containsGregorMD = !isUndefined(normalized.month) || !isUndefined(normalized.day),
       containsGregor = containsGregorYear || containsGregorMD,
@@ -625,12 +632,14 @@ export default class DateTime {
     // otherwise just use weeks or ordinals or gregorian, depending on what's specified
 
     if ((containsGregor || containsOrdinal) && definiteWeekDef) {
+      if (opts.nullOnInvalid) return null;
       throw new ConflictingSpecificationError(
         "Can't mix weekYear/weekNumber units with year/month/day or ordinals"
       );
     }
 
     if (containsGregorMD && containsOrdinal) {
+      if (opts.nullOnInvalid) return null;
       throw new ConflictingSpecificationError("Can't mix ordinal dates with month/day");
     }
 
diff --git a/src/duration.js b/src/duration.js
index 3e8dbe94..cc27a69f 100644
--- a/src/duration.js
+++ b/src/duration.js
@@ -224,14 +224,24 @@ export default class Duration {
    */
   static fromObject(obj, opts = {}) {
     if (obj == null || typeof obj !== "object") {
+      if (opts.nullOnInvalid) return null;
       throw new InvalidArgumentError(
         `Duration.fromObject: argument expected to be an object, got ${
           obj === null ? "null" : typeof obj
         }`
       );
     }
+
+    let values;
+    try {
+      values = normalizeObject(obj, Duration.normalizeUnit);
+    } catch (error) {
+      if (opts.nullOnInvalid) return null;
+      throw error;
+    }
+
     return new Duration({
-      values: normalizeObject(obj, Duration.normalizeUnit),
+      values,
       loc: Locale.fromObject(opts),
       conversionAccuracy: opts.conversionAccuracy
     });


Test patch:
diff --git a/test/datetime/create.test.js b/test/datetime/create.test.js
index 97471d10..c4af3b5e 100644
--- a/test/datetime/create.test.js
+++ b/test/datetime/create.test.js
@@ -5,7 +5,10 @@ import {
   UnitOutOfRangeError,
   InvalidArgumentError,
   MismatchedWeekdayError,
-  InvalidZoneError
+  InvalidZoneError,
+  ConflictingSpecificationError,
+  InvalidUnitError,
+  UnparsableStringError
 } from "../../src/errors";
 import Settings from "../../src/settings";
 
@@ -137,7 +140,7 @@ const badInputs = [
 ];
 
 test.each(badInputs)("Interval.fromISO will reject [%s]", s => {
-  expect(() => DateTime.fromISO(s)).toThrow();
+  expect(() => DateTime.fromISO(s)).toThrow(UnparsableStringError);
 });
 
 test("local with no options", () => {
@@ -351,12 +354,12 @@ test("DateTime.fromMillis accepts the default locale", () => {
 });
 
 test("DateTime.fromMillis(ms) throws InvalidArgumentError for non-numeric input", () => {
-  expect(() => DateTime.fromMillis("slurp")).toThrow();
+  expect(() => DateTime.fromMillis("slurp")).toThrow(InvalidArgumentError);
 });
 
 test("DateTime.fromMillis(ms) does not accept out-of-bounds numbers", () => {
-  expect(() => DateTime.fromMillis(-8.64e15 - 1)).toThrow();
-  expect(() => DateTime.fromMillis(8.64e15 + 1)).toThrow();
+  expect(() => DateTime.fromMillis(-8.64e15 - 1)).toThrow(InvalidArgumentError);
+  expect(() => DateTime.fromMillis(8.64e15 + 1)).toThrow(InvalidArgumentError);
 });
 
 //------
@@ -382,7 +385,7 @@ test("DateTime.fromSeconds accepts the default locale", () => {
 });
 
 test("DateTime.fromSeconds(seconds) throws InvalidArgumentError for non-numeric input", () => {
-  expect(() => DateTime.fromSeconds("slurp")).toThrow();
+  expect(() => DateTime.fromSeconds("slurp")).toThrow(InvalidArgumentError);
 });
 
 //------
@@ -493,17 +496,17 @@ test("DateTime.fromObject() ignores the case of object keys", () => {
 });
 
 test("DateTime.fromObject() throws with invalid object key", () => {
-  expect(() => DateTime.fromObject({ invalidUnit: 42 })).toThrow();
+  expect(() => DateTime.fromObject({ invalidUnit: 42 })).toThrow(InvalidUnitError);
 });
 
 test("DateTime.fromObject() throws with invalid value types", () => {
-  expect(() => DateTime.fromObject({ year: "blorp" })).toThrow();
-  expect(() => DateTime.fromObject({ year: "" })).toThrow();
-  expect(() => DateTime.fromObject({ month: NaN })).toThrow();
-  expect(() => DateTime.fromObject({ day: true })).toThrow();
-  expect(() => DateTime.fromObject({ day: false })).toThrow();
-  expect(() => DateTime.fromObject({ hour: {} })).toThrow();
-  expect(() => DateTime.fromObject({ hour: { unit: 42 } })).toThrow();
+  expect(() => DateTime.fromObject({ year: "blorp" })).toThrow(InvalidArgumentError);
+  expect(() => DateTime.fromObject({ year: "" })).toThrow(InvalidArgumentError);
+  expect(() => DateTime.fromObject({ month: NaN })).toThrow(InvalidArgumentError);
+  expect(() => DateTime.fromObject({ day: true })).toThrow(InvalidArgumentError);
+  expect(() => DateTime.fromObject({ day: false })).toThrow(InvalidArgumentError);
+  expect(() => DateTime.fromObject({ hour: {} })).toThrow(InvalidArgumentError);
+  expect(() => DateTime.fromObject({ hour: { unit: 42 } })).toThrow(InvalidArgumentError);
 });
 
 test("DateTime.fromObject() rejects invalid values", () => {
@@ -512,6 +515,26 @@ test("DateTime.fromObject() rejects invalid values", () => {
   expect(() => DateTime.fromObject({ millisecond: new Date() })).toThrow(UnitOutOfRangeError);
 });
 
+test("DateTime.fromObject() returns null with nullOnInvalid option", () => {
+  expect(DateTime.fromObject({ invalidUnit: 42 }, { nullOnInvalid: true })).toBe(null);
+  expect(DateTime.fromObject({}, { zone: "blorp", nullOnInvalid: true })).toBe(null);
+  expect(DateTime.fromObject({ weekYear: 2017, weekNumber: 54 }, { nullOnInvalid: true })).toBe(
+    null
+  );
+  expect(DateTime.fromObject({ day: true }, { nullOnInvalid: true })).toBe(null);
+  expect(DateTime.fromObject({ minute: -6 }, { nullOnInvalid: true })).toBe(null);
+  expect(
+    DateTime.fromObject({ year: 2005, months: 12, day: 13, weekday: 1 }, { nullOnInvalid: true })
+  ).toBe(null);
+
+  expect(DateTime.fromObject({ months: 7, ordinal: 200 }, { nullOnInvalid: true })).toBe(null);
+  expect(DateTime.fromObject({ day: 12, ordinal: 200 }, { nullOnInvalid: true })).toBe(null);
+  expect(DateTime.fromObject({ weekYear: 2005, ordinal: 222 }, { nullOnInvalid: true })).toBe(null);
+  expect(DateTime.fromObject({ weekNumber: 42, ordinal: 222 }, { nullOnInvalid: true })).toBe(null);
+  expect(DateTime.fromObject({ weekYear: 2005, month: 2 }, { nullOnInvalid: true })).toBe(null);
+  expect(DateTime.fromObject({ weekNumber: 42, day: 22 }, { nullOnInvalid: true })).toBe(null);
+});
+
 test("DateTime.fromObject() defaults high-order values to the current date", () => {
   const dateTime = DateTime.fromObject({}),
     now = DateTime.local();
@@ -655,6 +678,27 @@ test("DateTime.fromObject validates weekdays", () => {
   );
 });
 
+test("DateTime.fromObject rejects some ordinal, gregorian, week combinations", () => {
+  expect(() => DateTime.fromObject({ months: 7, ordinal: 200 })).toThrow(
+    ConflictingSpecificationError
+  );
+  expect(() => DateTime.fromObject({ day: 12, ordinal: 200 })).toThrow(
+    ConflictingSpecificationError
+  );
+  expect(() => DateTime.fromObject({ weekYear: 2005, ordinal: 222 })).toThrow(
+    ConflictingSpecificationError
+  );
+  expect(() => DateTime.fromObject({ weekNumber: 42, ordinal: 222 })).toThrow(
+    ConflictingSpecificationError
+  );
+  expect(() => DateTime.fromObject({ weekYear: 2005, month: 2 })).toThrow(
+    ConflictingSpecificationError
+  );
+  expect(() => DateTime.fromObject({ weekNumber: 42, day: 22 })).toThrow(
+    ConflictingSpecificationError
+  );
+});
+
 test("DateTime.fromObject accepts a locale", () => {
   const res = DateTime.fromObject({}, { locale: "be" });
   expect(res.locale).toBe("be");
diff --git a/test/duration/create.test.js b/test/duration/create.test.js
index b790047c..7d2728c2 100644
--- a/test/duration/create.test.js
+++ b/test/duration/create.test.js
@@ -1,6 +1,7 @@
 /* global test expect */
 
 import { Duration } from "../../src/luxon";
+import { InvalidArgumentError, InvalidUnitError } from "../../src/errors";
 
 //------
 // .fromObject()
@@ -55,9 +56,9 @@ test("Duration.fromObject accepts locale settings", () => {
 });
 
 test("Duration.fromObject throws if the argument is not an object", () => {
-  expect(() => Duration.fromObject()).toThrow();
-  expect(() => Duration.fromObject(null)).toThrow();
-  expect(() => Duration.fromObject("foo")).toThrow();
+  expect(() => Duration.fromObject()).toThrow(InvalidArgumentError);
+  expect(() => Duration.fromObject(null)).toThrow(InvalidArgumentError);
+  expect(() => Duration.fromObject("foo")).toThrow(InvalidArgumentError);
 });
 
 test("Duration.fromObject({}) constructs zero duration", () => {
@@ -72,17 +73,17 @@ test("Duration.fromObject({}) constructs zero duration", () => {
 });
 
 test("Duration.fromObject throws if the initial object has invalid keys", () => {
-  expect(() => Duration.fromObject({ foo: 0 })).toThrow();
-  expect(() => Duration.fromObject({ years: 1, foo: 0 })).toThrow();
+  expect(() => Duration.fromObject({ foo: 0 })).toThrow(InvalidUnitError);
+  expect(() => Duration.fromObject({ years: 1, foo: 0 })).toThrow(InvalidUnitError);
 });
 
 test("Duration.fromObject throws if the initial object has invalid values", () => {
-  expect(() => Duration.fromObject({ years: {} })).toThrow();
-  expect(() => Duration.fromObject({ months: "some" })).toThrow();
-  expect(() => Duration.fromObject({ days: NaN })).toThrow();
-  expect(() => Duration.fromObject({ hours: true })).toThrow();
-  expect(() => Duration.fromObject({ minutes: false })).toThrow();
-  expect(() => Duration.fromObject({ seconds: "" })).toThrow();
+  expect(() => Duration.fromObject({ years: {} })).toThrow(InvalidArgumentError);
+  expect(() => Duration.fromObject({ months: "some" })).toThrow(InvalidArgumentError);
+  expect(() => Duration.fromObject({ days: NaN })).toThrow(InvalidArgumentError);
+  expect(() => Duration.fromObject({ hours: true })).toThrow(InvalidArgumentError);
+  expect(() => Duration.fromObject({ minutes: false })).toThrow(InvalidArgumentError);
+  expect(() => Duration.fromObject({ seconds: "" })).toThrow(InvalidArgumentError);
 });
 
 test("Duration.fromObject is valid if providing options only", () => {
@@ -95,3 +96,17 @@ test("Duration.fromObject is valid if providing options only", () => {
   expect(dur.seconds).toBe(0);
   expect(dur.milliseconds).toBe(0);
 });
+
+test("Duration.fromObject returns null with nullOnInvalid option", () => {
+  expect(Duration.fromObject(undefined, { nullOnInvalid: true })).toBe(null);
+  expect(Duration.fromObject(null, { nullOnInvalid: true })).toBe(null);
+  expect(Duration.fromObject("foo", { nullOnInvalid: true })).toBe(null);
+
+  expect(Duration.fromObject({ invalidUnit: 42 }, { nullOnInvalid: true })).toBe(null);
+  expect(Duration.fromObject({ years: {} }, { nullOnInvalid: true })).toBe(null);
+  expect(Duration.fromObject({ months: "some" }, { nullOnInvalid: true })).toBe(null);
+  expect(Duration.fromObject({ days: NaN }, { nullOnInvalid: true })).toBe(null);
+  expect(Duration.fromObject({ hours: true }, { nullOnInvalid: true })).toBe(null);
+  expect(Duration.fromObject({ minutes: false }, { nullOnInvalid: true })).toBe(null);
+  expect(Duration.fromObject({ seconds: "" }, { nullOnInvalid: true })).toBe(null);
+});