3
\$\begingroup\$

I have a StudentSchedule class that contains schedules of a student, the student may switch between rooms over time. There's no overlap with the date ranges so if a student stops at 2020年01月01日 the next record would be 2020年01月02日.

Given the StudentSchedule, I want a ProgramEnrollment which disregards room changes and coalesces contiguous StudentSchedules.

So given the following StudentSchedules

 new StudentSchedule("A", "1", parse("2020-01-01"), parse("2020-01-02")),
 new StudentSchedule("B", "1", parse("2020-01-06"), parse("2020-01-10")),
 new StudentSchedule("B", "2", parse("2020-01-11"), null),
 new StudentSchedule("A", "2", parse("2020-01-03"), parse("2020-01-04"))

I want a result like

A 2020年01月01日 - 2020年01月04日
B 2020年01月06日 - null

I have extracted the relevant code below. What I want to do is see if I can change the computeProgramEnrollments() to use more functions or streaming API including groupBy then flatmap then collect to a new list. Assuming it is possible.

import java.time.*;
import java.util.*;
import java.util.stream.*;
import static java.time.LocalDate.*;
class Main {
 public static void main(String[] args) {
 System.out.println(computeProgramEnrollments());
 }
 static Stream<StudentSchedule> schedules() {
 return Stream.of(
 new StudentSchedule("A", "1", parse("2020-01-01"), parse("2020-01-02")),
 new StudentSchedule("B", "1", parse("2020-01-06"), parse("2020-01-10")),
 new StudentSchedule("B", "2", parse("2020-01-11"), null),
 new StudentSchedule("A", "2", parse("2020-01-03"), parse("2020-01-04"))
 );
 }
 public static List<ProgramEnrollment> computeProgramEnrollments() {
 List<ProgramEnrollment> ret = new ArrayList<>();
 String currentProgram = null;
 LocalDate currentStartDate = null;
 LocalDate currentStopDate = null;
 boolean newEnrollmentRequired = false;
 for (final StudentSchedule schedule : schedules().sorted(Comparator.comparing(StudentSchedule::getStartDate)).collect(Collectors.toList())) {
 if (Objects.equals(currentProgram, schedule.getProgram())) {
 if (currentStopDate != null && currentStopDate.plusDays(1).isEqual(schedule.getStartDate())) {
 // continuation
 currentStopDate = schedule.getStopDate();
 } else {
 newEnrollmentRequired = true;
 }
 } else {
 newEnrollmentRequired = true;
 }
 if (newEnrollmentRequired) {
 if (currentProgram != null) {
 final ProgramEnrollment e = 
 new ProgramEnrollment(currentProgram,
 currentStartDate,
 currentStopDate
 );
 ret.add(e);
 }
 currentProgram = schedule.getProgram();
 currentStartDate = schedule.getStartDate();
 currentStopDate = schedule.getStopDate();
 newEnrollmentRequired = false;
 }
 }
 if (currentProgram != null) {
 final ProgramEnrollment e = 
 new ProgramEnrollment(currentProgram,
 currentStartDate,
 currentStopDate
 );
 ret.add(e);
 }
 return ret;
 }
}
class StudentSchedule {
 String program;
 String room;
 LocalDate start;
 LocalDate stop;
 public StudentSchedule(String program, String room, LocalDate start, LocalDate stop) {
 this.program = program;
 this.room = room;
 this.start = start;
 this.stop = stop;
 }
 public String getProgram() { return program; }
 public String getRoom() { return room; }
 public LocalDate getStartDate() { return start; }
 public LocalDate getStopDate() { return stop; }
}
class ProgramEnrollment {
 String program;
 LocalDate start;
 LocalDate stop;
 public ProgramEnrollment(String program, LocalDate start, LocalDate stop) {
 this.program = program;
 this.start = start;
 this.stop = stop; 
 } 
 public String getProgram() { return program; }
 public LocalDate getStartDate() { return start; }
 public LocalDate getStopDate() { return stop; }
 public String toString() {
 return program + " " + start + "-" + stop + "\n";
 }
}

https://repl.it/@trajano/StupendousBetterEngines

asked May 7, 2020 at 21:52
\$\endgroup\$

2 Answers 2

2
\$\begingroup\$

This is a nice candidate for group-by/map-reduce!

(we need an additional stream with map to get rid of the optional that is standard in reducing)

Idea

The idea is to group each StudentSchedule by program, map them to a small ProgramEnrollment, then to reduce the values of each key by merge or coalesce these ProgramEnrollments and finally return a list of the reduced values.

Note that the reducing requires at least one entry in the stream to prevent Optional.empty()

You could even improve by returning a Stream<ProgramEnrollment>.

Note that it is possible to inline the reductor and mapper, but for clarity I introduced some local values.

Code

public static List<ProgramEnrollment> computeProgramEnrollments() {
 Collector<ProgramEnrollment, ?, Optional<ProgramEnrollment>> reductor = Collectors.reducing(ProgramEnrollment::merge);
 Collector<StudentSchedule, ?, Optional<ProgramEnrollment>> mapper = Collectors.mapping(ProgramEnrollment::from, reductor);
 return
 schedules()
 .sorted(Comparator.comparing(StudentSchedule::getStartDate))
 .collect(Collectors.groupingBy(StudentSchedule::getProgram, mapper))
 .values()
 .stream()
 .map(Optional::get).collect(Collectors.toList());
}

Code - using toMap

toMap allows for easier reducing and helps to get rid of the Optional.

public static List<ProgramEnrollment> computeProgramEnrollments() {
 return schedules()
 .sorted(Comparator.comparing(StudentSchedule::getStartDate))
 .collect(Collectors.toMap(StudentSchedule::getProgram,
 ProgramEnrollment::from,
 ProgramEnrollment::merge))
 .values()
 .stream()
 .collect(Collectors.toList());
}

With some additional methods here (they could also be extracted to a util / converted to static methods on Main) :

static class ProgramEnrollment {
 ...
 public static ProgramEnrollment from(StudentSchedule s) {
 return new ProgramEnrollment(s.getProgram(), s.getStartDate(), s.getStopDate());
 }
 public ProgramEnrollment merge(ProgramEnrollment e) {
 LocalDate minStart = this.start == null ? e.start : e.start == null ? this.start : e.start.isBefore(this.start) ? e.start : this.start;
 LocalDate maxStop = this.stop == null ? null : e.stop == null ? null : e.stop.isAfter(this.stop) ? e.stop : this.stop;
 return new ProgramEnrollment(this.program, minStart, maxStop);
 }
}
answered May 8, 2020 at 10:17
\$\endgroup\$
3
  • \$\begingroup\$ Not sure but I think your reducer does not handle the plusDays(1) \$\endgroup\$ Commented May 8, 2020 at 13:53
  • \$\begingroup\$ It does not seem to handle the gaps e.g. StudentSchdule(A, 2020年01月01日, 2020年01月02日), StudentSchdule(A, 2020年01月05日, 2020年01月10日) should have two resulting entries. \$\endgroup\$ Commented May 8, 2020 at 14:47
  • 1
    \$\begingroup\$ Oh I see. Good point! I didn't get that straight from the requirements. In that case, the reduce step should be replaced by a merge step that merges all the periods in one go, not too hard because the are sorted. If I find time I will fix it 👍 \$\endgroup\$ Commented May 8, 2020 at 15:53
1
\$\begingroup\$

This uses collect since reduce is meant for collecting with an immutable result and this is validated to use with parallel streams.

return schedules()
 .map(s->ProgramEnrollment.from(s)) // same as @RobAu
 .sorted(Comparator.comparing(ProgramEnrollment::getStartDate)) 
 .collect(
 ArrayList::new,
 (c, e)->{ 
 if (c.isEmpty()) {
 c.add(e);
 } else {
 var top = c.get(c.size() - 1);
 if (!top.getProgram().equals(e.getProgram())) {
 // Program changed
 c.add(e); 
 }
 else if (top.getStopDate() != null &&
 top.getStopDate().plusDays(1).isBefore(e.getStartDate())) {
 // At this point there is a gap with the program
 c.add(e); 
 }
 else if (top.getStopDate() != null && 
 top.getStopDate().plusDays(1).isEqual(e.getStartDate())) {
 // update the stop date with the new stop date
 top.setStopDate(e.getStopDate());
 }
 else {
 throw new IllegalStateException();
 }
 }
 },
 (c1, c2) -> {
 var topC1 = c1.get(c1.size() - 1);
 var botC2 = c2.get(0);
 if (!topC1.getProgram().equals(botC2.getProgram()) ||
 topC1.getStopDate() != null &&
 topC1.getStopDate().plusDays(1).isBefore(botC2.getStartDate())) {
 // Program changed or there is a gap with the program
 c1.addAll(c2);
 } else if (topC1.getStopDate() != null &&
 topC1.getStopDate().plusDays(1).isEqual(botC2.getStartDate())) {
 // update the stop date with the new stop date
 botC2.setStartDate(topC1.getStartDate());
 c1.remove(c1.size() - 1);
 c1.addAll(c2);
 } else {
 // handle cases when the data does not match the preconditions
 throw new IllegalStateException();
 }
 }
 );
answered May 11, 2020 at 20:20
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.