Search code examples
javahashmapjava-streamcollectorslocaldate

Group the contents of a Map of LocalDates into a Map of Ranges of dates


I have a map of working shifts indexed by day Map<LocalDate, Collection<Shift>> shiftsAllDays such as:

"2020-03-26": [
    {
      "id": 4,
      "startTime": "21:00:00"
    },
    {
      "id": 5,
      "startTime": "09:00:00",
    }
  ],
  "2020-03-27": [
    {
      "id": 4,
      "startTime": "22:00:00"
    },
    {
      "id": 5,
      "startTime": "10:00:00",
    }
  ],
  ...
]

as it can be seen, the shifts have an id and a start hour.

But to have the map indexed by day is a waste: the shifts happen to be consistently the same, except for one thing. The start hour is LocalTime, so the only difference in the shifts, is that after applying the timezone, the resulting shifts only differ in hour when a DST flip takes place.

I want to do some sort of groupBy in the collector, to have something of the like:

Range.of(2020-01-01 ... 2020-03-26) [
    {
      "id": 4,
      "startTime": "21:00:00"
    },
    {
      "id": 5,
      "startTime": "09:00:00",
    }
],
Range.of(2020-03-27 ... 2020-10-30) [
    {
      "id": 4,
      "startTime": "22:00:00"
    },
    {
      "id": 5,
      "startTime": "10:00:00",
    }
],
Range.of(2020-10-31 ... 2021-01-01) [
    {
      "id": 4,
      "startTime": "21:00:00"
    },
    {
      "id": 5,
      "startTime": "09:00:00",
    }
]

...grouped in Ranges from DST flip to DST flip. I'm having a hard time writing a Collectors.groupBy(...) and transforming the key from a single LocalDate day, to a Range<LocalDate>


Solution

  • Solution with custom Range class

    To begin with, you need to define a class Range with two fields (start date and end date), and create a list of ranges. Since only three instance are required, it makes sense to declare this list as a public static final field within a utility class.

    Below is an example of Range, for the purpose of conciseness I've implemented it as Java 16 record. It is given one utility method which is meant to check whether the given date is within this instance of range:

    public record Range(LocalDate start, LocalDate end) {
        public boolean isWithinRange(LocalDate date) { // start inclusive, end exclusive
            return date.isBefore(start) || date.isBefore(end) && date.isAfter(start);
        }
    }
    

    A list of ranges:

    public static final List<Range> RANGES =
        List.of(new Range(LocalDate.of(2020,1, 1), LocalDate.of(2020,3, 27)),
                new Range(LocalDate.of(2020,3, 27), LocalDate.of(2020,10, 31)),
                new Range(LocalDate.of(2020,10, 31), LocalDate.of(2021,1, 1)));
    

    Utility method getRange() that is responsible for retrieving an instance of Range from the list of ranges based on the given date:

    public static Range getRange(LocalDate data) {
        return RANGES.stream()
            .filter(range -> range.isWithinRange(data))
            .findFirst()
            .orElseThrow();
    }
    

    Dummy record Shift (used for testing purposes):

    public record Shift(int id, LocalTime startTime) {}
    

    In order to approach the conversion of a Map<LocalDate, Collection<Shift>> into a Map<Range, List<Shift>> with streams, we need to create a stream of map entries. Than make use of collector groupingBy() in order to group the data by range. As the downstream collector of groupingBy() we have to provide flatMapping() in order to flatten the data associated with a particular data (so that all Shift objects that will be mapped to the same range would be placed in the same collection). And a downstream of flatMapping() we need to provide a collector which defines how store (or how to perform reduction) flattened elements. In the data collector toList() is used to store the data mapped to the same key in a list.

    main() - demo

    public static void main(String[] args) {
        Map<LocalDate, Collection<Shift>> shiftsByDate =
            Map.of(LocalDate.of(2020,3, 26),
                       List.of(new Shift(4, LocalTime.of(21, 0, 0)), new Shift(5, LocalTime.of(9, 0, 0))),
                   LocalDate.of(2020,3, 27),
                       List.of(new Shift(4, LocalTime.of(22, 0, 0)), new Shift(5, LocalTime.of(10, 0, 0)))
                );
        
        Map<Range, List<Shift>> shiftsByRange = shiftsByDate.entrySet().stream()
            .collect(Collectors.groupingBy(entry -> getRange(entry.getKey()),
                Collectors.flatMapping(entry -> entry.getValue().stream(),
                    Collectors.toList())));
        
        shiftsByRange.forEach((k, v) -> System.out.println(k + " : " + v));
    }
    

    Output

    Range[start=2020-10-31, end=2021-01-01] : [Shift[id=4, startTime=22:00], Shift[id=5, startTime=10:00]]
    Range[start=2020-01-01, end=2020-03-27] : [Shift[id=4, startTime=21:00], Shift[id=5, startTime=09:00]]
    

    Solution with Range from the Spring Data

    The previous version required moderate only changes in order to use Range<T> from the Spring Data project. We need to fix the list RANGES where these objects are instantiated, and replace the type Range with the Range<T>.

    There's caveat: if you expect generic type parameter <T> to be LocalDate - it's not correct and wouldn't work.

    Range<T> class expects its generic type to be comparable, i.e. T extends Comparable<T> and LocalDate doesn't extend comparable directly, it implements ChronoLocalDate interface which in turn implements Comparable interface and LocalDate inherits its default implementation of compareTo().

    In other words:

    • it's incorrect to say that LocalDate extends Comparable<LocalDate>
    • because in fact LocalDate extends Comparable<ChronoLocalDate>.

    Therefore, we have to use ChronoLocalDate as a generic type.

    The RANGES list will look like that:

    public static final List<Range<ChronoLocalDate>> RANGES =
        List.of(Range.rightOpen(LocalDate.of(2020,1, 1), LocalDate.of(2020,3, 27)),
                Range.rightOpen(LocalDate.of(2020,3, 27), LocalDate.of(2020,10, 31)),
                Range.rightOpen(LocalDate.of(2020,10, 31), LocalDate.of(2021,1, 1)));
    

    Method getRange() which is responsible for retrieving an instance of Range from the list of ranges based on the given date:

    public static Range<ChronoLocalDate> getRange(LocalDate data) {
        return RANGES.stream()
            .filter(range -> range.contains(data))
            .findFirst()
            .orElseThrow();
    }
    

    main() - demo

    public static void main(String[] args) {
        Map<LocalDate, Collection<Shift>> shiftsByDate =
            Map.of(LocalDate.of(2020,3, 26),
                       List.of(new Shift(4, LocalTime.of(21, 0, 0)), new Shift(5, LocalTime.of(9, 0, 0))),
                   LocalDate.of(2020,3, 27),
                       List.of(new Shift(4, LocalTime.of(22, 0, 0)), new Shift(5, LocalTime.of(10, 0, 0)))
            );
        
        Map<Range<ChronoLocalDate>, List<Shift>> shiftsByRange = shiftsByDate.entrySet().stream()
            .collect(Collectors.groupingBy(entry -> getRange(entry.getKey()),
                Collectors.flatMapping(entry -> entry.getValue().stream(),
                    Collectors.toList())));
        
        shiftsByRange.forEach((k, v) -> System.out.println(k + " : " + v));
    }
    

    Output:

    [2020-03-27-2020-10-31) : [Shift[id=4, startTime=22:00], Shift[id=5, startTime=10:00]]
    [2020-01-01-2020-03-27) : [Shift[id=4, startTime=21:00], Shift[id=5, startTime=09:00]]