11

From the ISO-8601 standards, there are 4 ways of expressing intervals/duration:

  1. Start and end, such as "2007-03-01T13:00:00Z/2008-05-11T15:30:00Z"

  2. Start and duration, such as "2007-03-01T13:00:00Z/P1Y2M10DT2H30M"

  3. Duration and end, such as "P1Y2M10DT2H30M/2008-05-11T15:30:00Z"

  4. Duration only, such as "P1Y2M10DT2H30M", with additional context information

Using only Java 8 (no Joda, extensions, etc), is there any elegant way of handling cases 1-3?

I'm aware of Duration.Parse() and Period.Parse(), but I'm wondering if there's an elegant way of handling the 4 cases. For example:

String datePeriod = "2016-07-21/P6D";
String twoDates   = "2016-07-21/2016-07-25";

Duration d = Duration.parse(datePeriod); // DateTimeParseException
Duration p = Duration.parse(twoDates); // same

My current thought process is pretty sloppy, and I'm 100% sure there's a better way. Something like handling the 4 cases individually with nested try/catch blocks for each case, which seems a bit like an anti-pattern if anything. (Split on /, parse first chunk for date, check for errors, parse first chunk for period, parse second chunk for date, check for errors... you get the idea)

Any tips would be greatly appreciated!

--

Also, the answers at ISO 8601 Time Interval Parsing in Java don't really help me in any way, as the top answer only cares about the PT... stuff.

Ole V.V.
  • 81,772
  • 15
  • 137
  • 161
aphrid
  • 589
  • 8
  • 25
  • 7
    Off-topic fun fact: This is the 10,000th queston tagged java-8. – Ole V.V. Jun 19 '17 at 05:00
  • 4
    There’s no direct support for this. First, you will have to split your string at the slash, `/`, and parse each half separately. Second, Java has separated the `Period`, the years, months and days, from the `Duration` the hours and minutes, etc. I think I’d look into using both a `Period` and a `Duration` object, probably making sure the `Duration` was less than 24 hours. – Ole V.V. Jun 19 '17 at 05:05
  • Do start and end necessarily include zone offset information ( as `Z` in your examples)? Asking thinking `2017-06-01T13/2017-06-12T23` would make a fine interval/duration/period (11 days 10 hours). – Ole V.V. Jun 19 '17 at 05:09
  • To be honest, I haven't entirely delved into extensive ISO-8601 cases (they're borrowed conveniently from Wikipedia). For my current needs, they don't have a timezone, but if there's a solution that would encompass it, then it's all the merrier. – aphrid Jun 19 '17 at 05:20
  • 4
    I have now posted a [new answer at the linked question](https://stackoverflow.com/a/44622763/2491410) and hope it is useful for you. – Meno Hochschild Jun 19 '17 at 05:22
  • 3
    Your case # 1 is handled by the [`Interval`](http://www.threeten.org/threeten-extra/apidocs/org/threeten/extra/Interval.html) class of the [*ThreeTen-Extra*](http://www.threeten.org/threeten-extra/) project that extends the java.time classes. The class can parse and generate that standard format of `instant/instant`. – Basil Bourque Jun 19 '17 at 06:21
  • 2
    Based on my own implementation experiences with parsing instant/moment-intervals, I would say, handling end components with higher-order-elements left out as described in ISO-8601 (i.e. "2017-01-01T14:15Z/31T16:00") is the real hard nut to be cracked. I have not seen any suitable hooks in `java.time.format.DateTimeFormatter` to handle that case but could fortunately use/implement my own parse engine in Time4J (based on `ChronoFormatter`). – Meno Hochschild Jun 19 '17 at 07:53
  • 2
    Actually, the [ThreeTen-Extra project](http://www.threeten.org/threeten-extra) can handle all cases. For cases 1, 2 and 3, you can use the [`Interval.parse` method](http://www.threeten.org/threeten-extra/apidocs/org/threeten/extra/Interval.html#parse-java.lang.CharSequence-) - the only thing is that it accepts only complete offset dates (so, no locals here). And for case 4, [`PeriodDuration.parse` method](http://www.threeten.org/threeten-extra/apidocs/org/threeten/extra/PeriodDuration.html#parse-java.lang.CharSequence-) can be used. –  Jun 19 '17 at 12:57

2 Answers2

6

There's no real antipattern to splitting these things up. Oracle has split the responsibilities of these individual parsers up, and if we want to use them together in this sort of orchestration, it's up to us to ensure that we peel the pieces together again in a sensible fashion.

That said, I have a solution which works with core Java 8, and makes use of Function and a few custom classes. I'll omit the custom beans for brevity as they are fairly basic, as well as the fact that the main lift is done in the Functions.

Note that in order to get 'Z' to be recognized as a valid entry, you have to parse with DateTimeFormatter.ISO_DATE_TIME. Also, to ensure that your durations are properly picked up, prepend "PT" to the text that would fit in with durations. A more intelligent way to get that sort of detail from your existing string is an exercise I leave for the reader.

Function<String, Range> convertToRange = (dateString) -> {

    String[] dateStringParts = dateString.split("/");
    return new Range(LocalDateTime.parse(dateStringParts[0], DateTimeFormatter.ISO_DATE_TIME),
            LocalDateTime.parse(dateStringParts[1], DateTimeFormatter.ISO_DATE_TIME));
};

Function<String, DurationAndDateTime> convertToDurationAndDateTime = (dateString) -> {
    String[] dateStringParts = dateString.split("/");
    String[] durationAndPeriodParts = dateStringParts[1].split("T");
    return new DurationAndDateTime(Period.parse(durationAndPeriodParts[0]),
            Duration.parse("PT" + durationAndPeriodParts[1]),
            LocalDateTime.parse(dateStringParts[0], DateTimeFormatter.ISO_DATE_TIME));
};


Function<String, DurationAndDateTime> convertToDateTimeAndDuration = (dateString) -> {
    String[] dateStringParts = dateString.split("/");
    String[] durationAndPeriodParts = dateStringParts[0].split("T");
    return new DurationAndDateTime(Period.parse(durationAndPeriodParts[0]),
            Duration.parse("PT" + durationAndPeriodParts[1]),
            LocalDateTime.parse(dateStringParts[1], DateTimeFormatter.ISO_DATE_TIME));
};

Function<String, DurationOnly> convertToDurationOnlyRelativeToCurrentTime = (dateString) -> {
    String[] durationAndPeriodParts = dateString.split("T");
    return new DurationOnly(Period.parse(durationAndPeriodParts[0]),
            Duration.parse("PT" + durationAndPeriodParts[1]));
};
Makoto
  • 104,088
  • 27
  • 192
  • 230
2

I'm glad to solving your problem since it is a good example for introducing the Composite Design Pattern in Functional Programming. you can composing functions into a bigger and powerful single function. for example:

Function<String, Optional<Range<LocalDateTime>>> parser = anyOf(
        both(), //case 1
        starting(), //case 2
        ending(), //case 3
        since(LocalDateTime.now()) //case 4
);

Range<LocalDateTime> range = parser.apply("<INPUT>").orElse(null);

//OR using in stream as below
List<Range<LocalDateTime>> result = Stream.of(
    "<Case 1>", "<Case 2>", "<Case 3>", "<Case 4>"
).map(parser).filter(Optional::isPresent).map(Optional::get).collect(toList());

Let's introduce the code above each step by step

the code below almost applies the most of Design Patterns in OOP. e.g: Composite, Proxy, Adapter, Factory Method Design Patterns and .etc.

Functions

factory: the both method meet the 1st case as below:

static Function<String, Optional<Range<LocalDateTime>>> both() {
    return parsing((first, second) -> new Range<>(
            datetime(first),
            datetime(second)
    ));
}

factory: the starting method meet the 2nd case as below:

static Function<String, Optional<Range<LocalDateTime>>> starting() {
        return parsing((first, second) -> {
            LocalDateTime start = datetime(first);
            return new Range<>(start, start.plus(amount(second)));
        });
    }

factory: the ending method meet the 3rd case as below:

static Function<String, Optional<Range<LocalDateTime>>> ending() {
    return parsing((first, second) -> {
        LocalDateTime end = datetime(second);
        return new Range<>(end.minus(amount(first)), end);
    });
}

factory: the since method meet the last case as below:

static Function<String,Optional<Range<LocalDateTime>>> since(LocalDateTime start) {
    return parsing((amount, __) -> new Range<>(start, start.plus(amount(amount))));
}

composite : the responsibility of the anyOf method is find the satisfied result among the Functions as quickly as possible:

@SuppressWarnings("ConstantConditions")
static <T, R> Function<T, Optional<R>>
anyOf(Function<T, Optional<R>>... functions) {
    return it -> Stream.of(functions).map(current -> current.apply(it))
            .filter(Optional::isPresent)
            .findFirst().get();
}

adapter: the responsibility of the parsing method is create a parser for a certain input:

static <R> Function<String, Optional<R>> 
parsing(BiFunction<String, String, R> parser) {
    return splitting("/", exceptionally(optional(parser), Optional::empty));
}

proxy: the responsibility of the exceptionally method is handling Exceptions:

static <T, U, R> BiFunction<T, U, R>
exceptionally(BiFunction<T, U, R> source, Supplier<R> exceptional) {
    return (first, second) -> {
        try {
            return source.apply(first, second);
        } catch (Exception ex) {
            return exceptional.get();
        }
    };
}

adapter: the responsibility of the splitting method is translates a BiFunction to a Function:

static <R> Function<String, R>
splitting(String regex, BiFunction<String, String, R> source) {
    return value -> {
        String[] parts = value.split(regex);
        return source.apply(parts[0], parts.length == 1 ? "" : parts[1]);
    };
}

adapter: the responsibility of the optional method is create an Optional for the final result:

static <R> BiFunction<String, String, Optional<R>> 
optional(BiFunction<String, String, R> source) {
    return (first, last) -> Optional.of(source.apply(first, last));
}

Value Object:

the Range class for saving a ranged thing:

final class Range<T> {
    public final T start;
    public final T end;

    public Range(T start, T end) {
        this.start = start;
        this.end = end;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Range)) {
            return false;
        }
        Range<?> that = (Range<?>) o;

        return Objects.equals(start, that.start) && Objects.equals(end, that.end);
    }


    @Override
    public int hashCode() {
        return Objects.hash(start) * 31 + Objects.hash(end);
    }

    @Override
    public String toString() {
        return String.format("[%s, %s]", start, end);
    }
}

Utilities

the datetime method creates a LocalDateTime from a String:

static LocalDateTime datetime(String datetime) {
    return LocalDateTime.parse(
            datetime, 
            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss['Z']")
    );
}    

the amount method creates a TemporalAmount that takes both a Duration and a Period from a String:

static TemporalAmount amount(String text) {
    return splitting("T", (first, second) -> new TemporalAmount() {
        private Period period= first.isEmpty() ? Period.ZERO : Period.parse(first);
        private Duration duration = second.isEmpty() ? Duration.ZERO
                : Duration.parse(String.format("PT%s", second));

        @Override
        public long get(TemporalUnit unit) {
            return (period.getUnits().contains(unit) ? period.get(unit) : 0) +
                   (duration.getUnits().contains(unit) ? duration.get(unit) : 0);
        }

        @Override
        public List<TemporalUnit> getUnits() {
            return Stream.of(period, duration).map(TemporalAmount::getUnits)
                                              .flatMap(List::stream)
                                              .collect(toList());
        }

        @Override
        public Temporal addTo(Temporal temporal) {
            return period.addTo(duration.addTo(temporal));
        }

        @Override
        public Temporal subtractFrom(Temporal temporal) {
            return period.subtractFrom(duration.subtractFrom(temporal));
        }
    }).apply(text);
}
holi-java
  • 29,655
  • 7
  • 72
  • 83
  • 4
    One remark: We should avoid the term `Interval` for something which is just a combination of `java.time.Period` and `java.time.Duration` (finally another type of duration only). Intervals are anchored on the timeline, i.e. have a fixed start or end, in contrast to durations. – Meno Hochschild Jun 19 '17 at 10:44
  • @MenoHochschild thanks for your feedback, sir. I'm not good at English. how about `Moment`? – holi-java Jun 19 '17 at 11:06
  • 2
    My understanding (although I am also no native English speaker): `Moment` is a point in time (as implemented in my lib Time4J, other people call it `Instant`- like in Java-8-class), `Interval` is a section from start to end on a timeline (what you and other people also call `Range` - like in Guava). The threeten-extra-project has introduced a combination of `Period` and `Duration` as `PeriodDuration` (a pragmatic choice). By the way, I am not the downvoter... – Meno Hochschild Jun 19 '17 at 11:12
  • 1
    I wanted to say: If you choose `PeriodDuration` instead of `Interval`as naming then you would follow the conventions of the threeten-extra-project which is guided by the main author of `java.time`-package. However, it should be noted that this combination only covers an incomplete subset of duration features described in the original ISO-8601-paper. Furthermore, ISO-8601 describes a combination of amount/unit-tuples (mixing calendrical and clock-related ones) just as `Duration`. The term `Period` is inherited in Java-8 de facto from Joda-Time and was part of an outdated old ISO-8601-version. – Meno Hochschild Jun 19 '17 at 11:24
  • @MenoHochschild I know how to solve this problem just now. I just use `TemporalAmount` in java.time package. thanks again, sir. I'll fix my answer . – holi-java Jun 19 '17 at 11:42
  • @MenoHochschild sir, how about it now? I have inlined the class to avoding introduce a class with confused name. – holi-java Jun 19 '17 at 12:03
  • 2
    "Hey, `_` is forbidden in Java 8's lambda. So make sure we double it to give it even less sense!" – Olivier Grégoire Jun 19 '17 at 12:05
  • @OlivierGrégoire because I never used it, so I use 2 `_` underscores. :) – holi-java Jun 19 '17 at 12:09
  • 2
    Well, maybe the fact that you can't write `_` is a strong indicator to not use `__`? If you want your code to have sense, just name it `notUsed`, `ignored`, `skip`, `thisVariableIsNotUsedButAtLeastYouKnowItNow` (even `x` is better than two underscores). `__` (and friends) bears no semantics in Java and it makes the code harder to read because as readers, we see whitespaces. Java isn't Perl, Java isn't Python. Write code not for yourself, but for others. You don't know what the next guy's programming languages luggage is made of. – Olivier Grégoire Jun 19 '17 at 12:37
  • @OlivierGrégoire thanks for your advice. but this is a personal programming convention. I prefer to `_` but java don't support, so I double it to achieve my convention. – holi-java Jun 19 '17 at 12:44
  • 4
    Yep, that's what I understood: "personal", exactly. – Olivier Grégoire Jun 19 '17 at 12:57
  • Exceptions should not be used for flow control and returning lambdas could be replaced by method references. Rather a matter of taste: Improved readability by simple null checks instead of `Optional::isPresent`. – Knight Industries Mar 21 '22 at 16:57