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";
}
}
2 Answers 2
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);
}
}
-
\$\begingroup\$ Not sure but I think your reducer does not handle the
plusDays(1)
\$\endgroup\$Archimedes Trajano– Archimedes Trajano2020年05月08日 13:53:19 +00:00Commented 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\$Archimedes Trajano– Archimedes Trajano2020年05月08日 14:47:56 +00:00Commented 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\$Rob Audenaerde– Rob Audenaerde2020年05月08日 15:53:57 +00:00Commented May 8, 2020 at 15:53
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();
}
}
);