diff --git a/hypersistence-utils-hibernate-62/src/main/java/io/hypersistence/utils/hibernate/type/range/PostgreSQLRangeType.java b/hypersistence-utils-hibernate-62/src/main/java/io/hypersistence/utils/hibernate/type/range/PostgreSQLRangeType.java index 4ea38fee0..1c24c78fa 100644 --- a/hypersistence-utils-hibernate-62/src/main/java/io/hypersistence/utils/hibernate/type/range/PostgreSQLRangeType.java +++ b/hypersistence-utils-hibernate-62/src/main/java/io/hypersistence/utils/hibernate/type/range/PostgreSQLRangeType.java @@ -16,23 +16,21 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Types; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; +import java.time.*; import java.util.Properties; /** * Maps a {@link Range} object type to a PostgreSQL range * column type. *

- * Supported range types: + * Supported PostgreSQL range types: *

*

* For more details about how to use it, @@ -69,10 +67,11 @@ protected Range get(ResultSet rs, int position, SharedSessionContractImplementor return null; } - String type = ReflectionUtils.invokeGetter(pgObject, "type"); + String colType = ReflectionUtils.invokeGetter(pgObject, "type"); String value = ReflectionUtils.invokeGetter(pgObject, "value"); - switch (type) { + Class rangeClass = rangeClass(); + switch (colType) { case "int4range": return Range.integerRange(value); case "int8range": @@ -81,13 +80,25 @@ protected Range get(ResultSet rs, int position, SharedSessionContractImplementor return Range.bigDecimalRange(value); case "tsrange": return Range.localDateTimeRange(value); - case "tstzrange": - return Range.zonedDateTimeRange(value); + case "tstzrange": { + if (rangeClass != null && Instant.class.isAssignableFrom(rangeClass)) { + return Range.instantRange(value); + } + if (rangeClass != null && OffsetDateTime.class.isAssignableFrom(rangeClass)) { + return Range.offsetDateTimeRange(value); + } + if (rangeClass != null && ZonedDateTime.class.isAssignableFrom(rangeClass)) { + return Range.zonedDateTimeRange(value); + } + throw new HibernateException( + new IllegalStateException("The database column type [" + colType + "] must be mapped to one of Java types: Range, Range or Range.") + ); + } case "daterange": return Range.localDateRange(value); default: throw new HibernateException( - new IllegalStateException("The range type [" + type + "] is not supported!") + new IllegalStateException("The database column type [" + colType + "] is not supported!") ); } } @@ -116,6 +127,10 @@ private static String determineRangeType(Range range) { return "numrange"; } else if (clazz.equals(LocalDateTime.class)) { return "tsrange"; + } else if (clazz.equals(Instant.class)) { + return "tstzrange"; + } else if (clazz.equals(OffsetDateTime.class)) { + return "tstzrange"; } else if (clazz.equals(ZonedDateTime.class)) { return "tstzrange"; } else if (clazz.equals(LocalDate.class)) { @@ -160,6 +175,12 @@ public Range fromStringValue(CharSequence sequence) throws HibernateException { if(LocalDateTime.class.isAssignableFrom(clazz)) { return Range.localDateTimeRange(stringValue); } + if(Instant.class.isAssignableFrom(clazz)) { + return Range.instantRange(stringValue); + } + if(OffsetDateTime.class.isAssignableFrom(clazz)) { + return Range.offsetDateTimeRange(stringValue); + } if(ZonedDateTime.class.isAssignableFrom(clazz)) { return Range.zonedDateTimeRange(stringValue); } diff --git a/hypersistence-utils-hibernate-62/src/main/java/io/hypersistence/utils/hibernate/type/range/Range.java b/hypersistence-utils-hibernate-62/src/main/java/io/hypersistence/utils/hibernate/type/range/Range.java index 75f94c65e..1496b46f9 100644 --- a/hypersistence-utils-hibernate-62/src/main/java/io/hypersistence/utils/hibernate/type/range/Range.java +++ b/hypersistence-utils-hibernate-62/src/main/java/io/hypersistence/utils/hibernate/type/range/Range.java @@ -40,6 +40,7 @@ public final class Range> implements Serializabl public static final String INFINITY = "infinity"; + // Text pattern for 'TIMESTAMP' as used by the database private static final DateTimeFormatter LOCAL_DATE_TIME = new DateTimeFormatterBuilder() .appendPattern("yyyy-MM-dd HH:mm:ss") .optionalStart() @@ -48,12 +49,10 @@ public final class Range> implements Serializabl .optionalEnd() .toFormatter(); - private static final DateTimeFormatter ZONE_DATE_TIME = new DateTimeFormatterBuilder() - .appendPattern("yyyy-MM-dd HH:mm:ss") - .optionalStart() - .appendPattern(".") - .appendFraction(ChronoField.NANO_OF_SECOND, 1, 6, false) - .optionalEnd() + // Text pattern for 'TIMESTAMP WITH TIMEZONE' as used by the database when values are retrieved + // from the database. + private static final DateTimeFormatter OFFSET_DATE_TIME = new DateTimeFormatterBuilder() + .append(LOCAL_DATE_TIME) .appendOffset("+HH:mm", "Z") .toFormatter(); @@ -68,7 +67,7 @@ private Range(T lower, T upper, int mask, Class clazz) { this.mask = mask; this.clazz = clazz; - if (isBounded() && lower != null && upper != null && lower.compareTo(upper) > 0) { + if (isBounded() && lower != null && upper != null && compare(lower, upper, true) > 0) { throw new IllegalArgumentException("The lower bound is greater then upper!"); } } @@ -465,6 +464,66 @@ public static Range zonedDateTimeRange(String rangeStr) { return range; } + /** + * Creates the {@code OffsetDateTime} range from provided string: + *

{@code
+     *     Range closed = Range.offsetDateTimeRange("[2007-12-03T10:15:30+01:00\",\"2008-12-03T10:15:30+01:00]");
+     *     Range quoted = Range.offsetDateTimeRange("[\"2007-12-03T10:15:30+01:00\",\"2008-12-03T10:15:30+01:00\"]");
+     *     Range iso = Range.offsetDateTimeRange("[2011-12-03T10:15:30+01:00, 2012-12-03T10:15:30+01:00]");
+     * }
+ *

+ * The valid formats for bounds are: + *

+ * + * @param rangeStr The range string, for example {@literal "[2011-12-03T10:15:30+01:00,2012-12-03T10:15:30+01:00]"}. + * + * @return The range of {@code ZonedDateTime}s. + * + * @throws DateTimeParseException when one of the bounds are invalid. + * @throws IllegalArgumentException when bounds time zones are different. + */ + public static Range offsetDateTimeRange(String rangeStr) { + Range range = ofString(rangeStr, parseOffsetDateTime().compose(unquote()), OffsetDateTime.class); + if (range.hasLowerBound() && range.hasUpperBound() && !EMPTY.equals(rangeStr)) { + ZoneOffset lowerOffset = range.lower.getOffset(); + ZoneOffset upperOffset = range.upper.getOffset(); + if (!Objects.equals(lowerOffset, upperOffset)) { + throw new IllegalArgumentException("The upper and lower bounds must be in same time zone!"); + } + } + return range; + } + + /** + * Creates the {@code Instant} range from provided string: + *
{@code
+     *     Range closed1 = Range.instantRange("[2007-12-03T10:15:30Z\",\"2008-12-03T10:15:30Z]");
+     *     Range closed2 = Range.instantRange("[2007-12-03T10:15:30+01:00\",\"2008-12-03T10:15:30+01:00]");
+     *     Range quoted = Range.instantRange("[\"2007-12-03T10:15:30+01:00\",\"2008-12-03T10:15:30+01:00\"]");
+     *     Range iso = Range.instantRange("[2011-12-03T10:15:30+01:00, 2012-12-03T10:15:30+01:00]");
+     * }
+ *

+ * The valid formats for bounds are: + *

+ * + *

+ * As can be seen, for convenience, offset based string formats are supported too. This is because {@code OffsetDateTime} + * has a direct and unambiguous conversion to {@code Instant}. + * + * @param rangeStr The range string, for example {@literal "[2011-12-03T10:15:30+01:00,2012-12-03T10:15:30+01:00]"}. + * @return The range of {@code ZonedDateTime}s. + * @throws DateTimeParseException when one of the bounds are invalid. + */ + public static Range instantRange(String rangeStr) { + return ofString(rangeStr, parseInstant().compose(unquote()), Instant.class); + } + private static Function parseLocalDateTime() { return s -> { try { @@ -475,10 +534,30 @@ private static Function parseLocalDateTime() { }; } + private static Function parseInstant() { + return s -> { + try { + return OffsetDateTime.parse(s, OFFSET_DATE_TIME).toInstant(); + } catch (DateTimeParseException e) { + return OffsetDateTime.parse(s).toInstant(); + } + }; + } + + private static Function parseOffsetDateTime() { + return s -> { + try { + return OffsetDateTime.parse(s, OFFSET_DATE_TIME); + } catch (DateTimeParseException e) { + return OffsetDateTime.parse(s); + } + }; + } + private static Function parseZonedDateTime() { return s -> { try { - return ZonedDateTime.parse(s, ZONE_DATE_TIME); + return ZonedDateTime.parse(s, OFFSET_DATE_TIME); } catch (DateTimeParseException e) { return ZonedDateTime.parse(s); } @@ -515,6 +594,36 @@ public int hashCode() { return Objects.hash(lower, upper, mask, clazz); } + /** + * Indicates if another range, {@code o}, is equal to this one. + * This method produces the same result as method {@link #equals(Object)} except for + * {@code OffsetDateTime}-ranges and {@code ZonedDateTime}-ranges where this method performs + * comparison based on Instant equivalents, rather than comparing {@code OffsetDateTime}/{@code ZonedDateTime} + * directly. + * + *

+ * Consider the following two OffsetDateTime or ZonedDateTime ranges: + *

+     *      ['2007-12-03T09:30.00Z',)
+     *      ['2007-12-03T10:30.00+01:00',)
+     * 
+ * + * As can be seen both timestamp values refer to the same Instant in time. This method returns + * {@code true} for such comparison while {@link #equals(Object)} returns {@code false}. + * + * + * @param o other Range + * @return true if equal + */ + public boolean equalsInValue(Range o) { + if (this == o) return true; + if (o == null) return false; + return mask == o.mask && + (compare(lower, o.lower, true) == 0) && + (compare(upper, o.upper, true) == 0) && + Objects.equals(clazz, o.clazz); + } + @Override public String toString() { return "Range{" + "lower=" + lower + @@ -567,22 +676,39 @@ public T upper() { *

* For example: *

{@code
-     *     assertTrue(integerRange("[1,2]").contains(1))
-     *     assertTrue(integerRange("[1,2]").contains(2))
-     *     assertTrue(integerRange("[-1,1]").contains(0))
-     *     assertTrue(infinity(Integer.class).contains(Integer.MAX_VALUE))
-     *     assertTrue(infinity(Integer.class).contains(Integer.MIN_VALUE))
-     *
-     *     assertFalse(integerRange("(1,2]").contains(1))
-     *     assertFalse(integerRange("(1,2]").contains(3))
-     *     assertFalse(integerRange("[-1,1]").contains(0))
+     *     assertTrue(integerRange("[1,2]").contains(1, true))
+     *     assertTrue(integerRange("[1,2]").contains(2, true))
+     *     assertTrue(integerRange("[-1,1]").contains(0, true))
+     *     assertTrue(infinity(Integer.class).contains(Integer.MAX_VALUE, true))
+     *     assertTrue(infinity(Integer.class).contains(Integer.MIN_VALUE, true))
+     *
+     *     assertFalse(integerRange("(1,2]").contains(1, true))
+     *     assertFalse(integerRange("(1,2]").contains(3, true))
+     *     assertFalse(integerRange("[-1,1]").contains(0, true))
      * }
* - * @param point The point to check. + *

+ * For {@code OffsetDateTime}-ranges and {@code ZonedDateTime}-ranges: The {@code ic} parameter determines + * how values are compared. Consider the following two OffsetDateTime or ZonedDateTime values: + *

    + *
  1. '2007-12-03T09:30.00Z'
  2. + *
  3. '2007-12-03T10:30.00+01:00'
  4. + *
+ * As can be seen both values refer to the same instant in time. This type of jitter it likely to happen + * with databases: you persist the value (1) but when you later read it back from the database, it may have morphed into + * (2) depending on the time zone of your JVM. With standard comparison ({@code ic == false}) one of them would + * be evaluated as larger than the other one. This is likely to give unexpected results from this method. By contrast, + * if {@code ic == false}, then the two values would be evaluated as equals: none is larger or smaller than the other. + * + * It is recommended to always use {@code ic = true} when range type is {@code OffsetDateTime} or {@code ZonedDateTime}. + * * + * @param point The point to check. + * @param ic {@code true} if comparison based on Instant values should be performed. Ignored unless + * range type is {@code OffsetDateTime} or {@code ZonedDateTime}. * @return Whether {@code point} in this range or not. */ - public boolean contains(T point) { + public boolean contains(T point, boolean ic) { if (isEmpty()) { return false; } @@ -591,20 +717,84 @@ public boolean contains(T point) { boolean u = hasUpperBound(); if (l && u) { - boolean inLower = hasMask(LOWER_INCLUSIVE) ? lower.compareTo(point) <= 0 : lower.compareTo(point) < 0; - boolean inUpper = hasMask(UPPER_INCLUSIVE) ? upper.compareTo(point) >= 0 : upper.compareTo(point) > 0; + boolean inLower = hasMask(LOWER_INCLUSIVE) ? compare(lower, point, ic) <= 0 : compare(lower, point, ic) < 0; + boolean inUpper = hasMask(UPPER_INCLUSIVE) ? compare(upper, point, ic) >= 0 : compare(upper, point, ic)> 0; return inLower && inUpper; } else if (l) { - return hasMask(LOWER_INCLUSIVE) ? lower.compareTo(point) <= 0 : lower.compareTo(point) < 0; + return hasMask(LOWER_INCLUSIVE) ? compare(lower, point, ic)<= 0 : compare(lower, point, ic) < 0; } else if (u) { - return hasMask(UPPER_INCLUSIVE) ? upper.compareTo(point) >= 0 : upper.compareTo(point) > 0; + return hasMask(UPPER_INCLUSIVE) ? compare(upper, point, ic) >= 0 : compare(upper, point, ic) > 0; } // INFINITY return true; } + + /** + * Determines whether this range contains this point or not. This method is equivalent + * to {@link #contains(Comparable, boolean) contains(point, true)}- + *

+ * For example: + *

{@code
+     *     assertTrue(integerRange("[1,2]").contains(1))
+     *     assertTrue(integerRange("[1,2]").contains(2))
+     *     assertTrue(integerRange("[-1,1]").contains(0))
+     *     assertTrue(infinity(Integer.class).contains(Integer.MAX_VALUE))
+     *     assertTrue(infinity(Integer.class).contains(Integer.MIN_VALUE))
+     *
+     *     assertFalse(integerRange("(1,2]").contains(1))
+     *     assertFalse(integerRange("(1,2]").contains(3))
+     *     assertFalse(integerRange("[-1,1]").contains(0))
+     * }
+ * + *

+ * For ranges of type {@code OffsetDateTime} or {@code ZonedDateTime} you probably + * want to use method {@link #contains(Comparable, boolean) contains(point, true)} instead. + * + * @see #contains(Comparable, boolean) + * @param point The point to check. + * @return Whether {@code point} in this range or not. + */ + public boolean contains(T point) { + return contains(point, false); + } + + private int compare(T t1, T t2, boolean instantComparison) { + + if (instantComparison) { + if (t1 instanceof OffsetDateTime && t2 instanceof OffsetDateTime) { + OffsetDateTime t1x = (OffsetDateTime) t1; + OffsetDateTime t2x = (OffsetDateTime) t2; + if (t1x.isEqual(t2x)) { + return 0; + } + if (t1x.isBefore(t2x)) { + return -1; + } + if (t1x.isAfter(t2x)) { + return 1; + } + } + if (t1 instanceof ZonedDateTime && t2 instanceof ZonedDateTime) { + ZonedDateTime t1x = (ZonedDateTime) t1; + ZonedDateTime t2x = (ZonedDateTime) t2; + if (t1x.isEqual(t2x)) { + return 0; + } + if (t1x.isBefore(t2x)) { + return -1; + } + if (t1x.isAfter(t2x)) { + return 1; + } + } + } + return t1.compareTo(t2); + } + + /** * Determines whether this range contains this range or not. *

@@ -618,11 +808,10 @@ public boolean contains(T point) { * } * * @param range The range to check. - * * @return Whether {@code range} in this range or not. */ public boolean contains(Range range) { - return !isEmpty() && (!range.hasLowerBound() || contains(range.lower)) && (!range.hasUpperBound() || contains(range.upper)); + return !isEmpty() && (!range.hasLowerBound() || contains(range.lower, true)) && (!range.hasUpperBound() || contains(range.upper, true)); } /** @@ -641,7 +830,7 @@ public boolean isEmpty() { public boolean hasEqualBounds() { return lower == null && upper == null - || lower != null && upper != null && lower.compareTo(upper) == 0; + || lower != null && upper != null && compare(lower, upper, true) == 0; } public boolean isBoundedOpen() { @@ -667,7 +856,15 @@ public String asString() { private Function boundToString() { return t -> { if (clazz.equals(ZonedDateTime.class)) { - return ZONE_DATE_TIME.format((ZonedDateTime) t); + // Let Java do the conversion from ZonedDateTime to OffsetDateTime. + // (For PostgreSQL: we could let database do the conversion instead, but better let Java handle it) + return OFFSET_DATE_TIME.format(((ZonedDateTime) t).toOffsetDateTime()); + } + if (clazz.equals(OffsetDateTime.class)) { + return OFFSET_DATE_TIME.format((OffsetDateTime) t); + } + if (clazz.equals(Instant.class)) { + return OFFSET_DATE_TIME.format(((Instant) t).atOffset(ZoneOffset.UTC)); } return t.toString(); diff --git a/hypersistence-utils-hibernate-62/src/test/java/io/hypersistence/utils/hibernate/type/range/PostgreSQLRangeTypeTest.java b/hypersistence-utils-hibernate-62/src/test/java/io/hypersistence/utils/hibernate/type/range/PostgreSQLRangeTypeTest.java index ca5ba6ebe..0bdcad415 100644 --- a/hypersistence-utils-hibernate-62/src/test/java/io/hypersistence/utils/hibernate/type/range/PostgreSQLRangeTypeTest.java +++ b/hypersistence-utils-hibernate-62/src/test/java/io/hypersistence/utils/hibernate/type/range/PostgreSQLRangeTypeTest.java @@ -6,13 +6,9 @@ import org.junit.Test; import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; +import java.time.*; -import static io.hypersistence.utils.hibernate.type.range.Range.infinite; -import static io.hypersistence.utils.hibernate.type.range.Range.zonedDateTimeRange; +import static io.hypersistence.utils.hibernate.type.range.Range.*; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -33,7 +29,11 @@ public class PostgreSQLRangeTypeTest extends AbstractPostgreSQLIntegrationTest { private final Range localDateTimeRange = Range.localDateTimeRange("[2014-04-28 16:00:49,2015-04-28 16:00:49]"); - private final Range tsTz = zonedDateTimeRange("[\"2007-12-03T10:15:30+01:00\",\"2008-12-03T10:15:30+01:00\"]"); + private final Range tsTzZdt = zonedDateTimeRange("[\"2007-12-03T10:15:30+01:00\",\"2008-12-03T10:15:30+01:00\"]"); + + private final Range tsTzIns = instantRange("[\"2007-12-03T10:15:30+01:00\",\"2008-12-03T10:15:30+01:00\"]"); + + private final Range tsTzOdt = offsetDateTimeRange("[\"2007-12-03T10:15:30+01:00\",\"2008-12-03T10:15:30+01:00\"]"); private final Range tsTzEmpty = zonedDateTimeRange("empty"); @@ -60,7 +60,9 @@ public void test() { restriction.setRangeLong(int8Range); restriction.setRangeBigDecimal(numeric); restriction.setRangeLocalDateTime(localDateTimeRange); - restriction.setRangeZonedDateTime(tsTz); + restriction.setRangeZonedDateTime(tsTzZdt); + restriction.setRangeInstant(tsTzIns); + restriction.setRangeOffsetDateTime(tsTzOdt); restriction.setRangeZonedDateTimeInfinity(infinityTsTz); restriction.setRangeZonedDateTimeEmpty(tsTzEmpty); restriction.setRangeLocalDate(dateRange); @@ -82,12 +84,14 @@ public void test() { ZoneId zone = restriction.getRangeZonedDateTime().lower().getZone(); - ZonedDateTime lower = tsTz.lower().withZoneSameInstant(zone); - ZonedDateTime upper = tsTz.upper().withZoneSameInstant(zone); + ZonedDateTime lower = tsTzZdt.lower().withZoneSameInstant(zone); + ZonedDateTime upper = tsTzZdt.upper().withZoneSameInstant(zone); assertEquals(restriction.getRangeZonedDateTime(), Range.closed(lower, upper)); lower = infinityTsTz.lower().withZoneSameInstant(zone); assertEquals(restriction.getRangeZonedDateTimeInfinity(), Range.closedInfinite(lower)); + + }); } @@ -149,9 +153,17 @@ public static class Restriction { private Range rangeLocalDateTime; @Type(PostgreSQLRangeType.class) - @Column(name = "r_ts_tz", columnDefinition = "tstzrange") + @Column(name = "r_ts_tz_zdt", columnDefinition = "tstzrange") private Range rangeZonedDateTime; + @Type(PostgreSQLRangeType.class) + @Column(name = "r_ts_tz_ins", columnDefinition = "tstzrange") + private Range rangeInstant; + + @Type(PostgreSQLRangeType.class) + @Column(name = "r_ts_tz_odt", columnDefinition = "tstzrange") + private Range rangeOffsetDateTime; + @Type(PostgreSQLRangeType.class) @Column(name = "r_ts_tz_infinity", columnDefinition = "tstzrange") private Range rangeZonedDateTimeInfinity; @@ -224,6 +236,22 @@ public void setRangeZonedDateTime(Range rangeZonedDateTime) { this.rangeZonedDateTime = rangeZonedDateTime; } + public Range getRangeInstant() { + return rangeInstant; + } + + public void setRangeInstant(Range rangeInstant) { + this.rangeInstant = rangeInstant; + } + + public Range getRangeOffsetDateTime() { + return rangeOffsetDateTime; + } + + public void setRangeOffsetDateTime(Range rangeOffsetDateTime) { + this.rangeOffsetDateTime = rangeOffsetDateTime; + } + public Range getRangeZonedDateTimeInfinity() { return rangeZonedDateTimeInfinity; } diff --git a/hypersistence-utils-hibernate-62/src/test/java/io/hypersistence/utils/hibernate/type/range/RangeTest.java b/hypersistence-utils-hibernate-62/src/test/java/io/hypersistence/utils/hibernate/type/range/RangeTest.java index a1ed25ce9..06ed40d2c 100644 --- a/hypersistence-utils-hibernate-62/src/test/java/io/hypersistence/utils/hibernate/type/range/RangeTest.java +++ b/hypersistence-utils-hibernate-62/src/test/java/io/hypersistence/utils/hibernate/type/range/RangeTest.java @@ -85,6 +85,27 @@ public void zonedDateTimeTest() { assertNotNull(Range.zonedDateTimeRange("[2019-03-27 16:33:10.123456-06,infinity)")); } + public void instantTest() { + assertNotNull(Range.instantRange("[2019-03-27 16:33:10.1-06,)")); + assertNotNull(Range.instantRange("[2019-03-27 16:33:10.12-06,)")); + assertNotNull(Range.instantRange("[2019-03-27 16:33:10.123-06,)")); + assertNotNull(Range.instantRange("[2019-03-27 16:33:10.1234-06,)")); + assertNotNull(Range.instantRange("[2019-03-27 16:33:10.12345-06,)")); + assertNotNull(Range.instantRange("[2019-03-27 16:33:10.123456-06,)")); + assertNotNull(Range.instantRange("[2019-03-27 16:33:10.123456+05:30,)")); + assertNotNull(Range.instantRange("[2019-03-27 16:33:10.123456-06,infinity)")); + } + + public void offsetDateTimeTest() { + assertNotNull(Range.offsetDateTimeRange("[2019-03-27 16:33:10.1-06,)")); + assertNotNull(Range.offsetDateTimeRange("[2019-03-27 16:33:10.12-06,)")); + assertNotNull(Range.offsetDateTimeRange("[2019-03-27 16:33:10.123-06,)")); + assertNotNull(Range.offsetDateTimeRange("[2019-03-27 16:33:10.1234-06,)")); + assertNotNull(Range.offsetDateTimeRange("[2019-03-27 16:33:10.12345-06,)")); + assertNotNull(Range.offsetDateTimeRange("[2019-03-27 16:33:10.123456-06,)")); + assertNotNull(Range.offsetDateTimeRange("[2019-03-27 16:33:10.123456+05:30,)")); + assertNotNull(Range.offsetDateTimeRange("[2019-03-27 16:33:10.123456-06,infinity)")); + } @Test public void emptyInfinityEquality() {