Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Java - Elegant way of parsing date/period?

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.

like image 968
aphrid Avatar asked Jun 19 '17 03:06

aphrid


2 Answers

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);
}
like image 141
holi-java Avatar answered Sep 19 '22 11:09

holi-java


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]));
};
like image 31
Makoto Avatar answered Sep 21 '22 11:09

Makoto