Search code examples
javaspring-bootoptaplanner

Optaplanner solutionClass entityCollectionProperty should never return null error when simple JSON object passed to controller


I am working off of the optaplanner-spring-boot-starter cloud-balancing project and I am trying to assign shifts to employees based on their skill level. However when I pass a JSON object to my roster controller I get an error saying:

java.lang.IllegalArgumentException: The solutionClass (class com.redhat.optaplannersbs.domain.Roster)'s entityCollectionProperty (bean property shiftList on class com.redhat.optaplannersbs.domain.Roster) should never return null.

I do not understand what the problem is as I am basically doing the same thing as the cloud-balancing problem and that runs and solves fine.

Here is my code for the employee class:

public class Employee {
    private int eid;

    private String name;

    private int skillLevel;

    public Employee(){

    }

    public int getEid() {
        return eid;
    }

    public void setEid(int eid) {
        this.eid = eid;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    // constraint getters and setters
    public int getSkillLevel() {
        return skillLevel;
    }

    public void setSkillLevel(int skillLevel) {
        this.skillLevel = skillLevel;
    }


}

Here is my code for the shift class:

 @PlanningEntity
public class Shift {
    private int sid;

    private LocalTime startTime;

    private LocalTime endTime;

    private int requiredSkillLevel;

    @PlanningVariable(valueRangeProviderRefs = "employee")
    private Employee employee;

    public Shift(){

    }

    public Shift(Long deptId, Long spotId, LocalTime startTime,
                 LocalTime endTime, Long employeeId){
        this.startTime = startTime;
        this.endTime = endTime;
    }

    public int getSid() {
        return sid;
    }

    public void setSid(int sid) {
        this.sid = sid;
    }

    public LocalTime getStartTime() {
        return startTime;
    }

    public void setStartTime(LocalTime startTime) {
        this.startTime = startTime;
    }

    public LocalTime getEndTime() {
        return endTime;
    }

    public void setEndTime(LocalTime endTime) {
        this.endTime = endTime;
    }


    // planning variable getter and setter

    public Employee getEmployee() {
        return employee;
    }

    public void setEmployee(Employee employee) {
        this.employee = employee;
    }

    public int getRequiredSkillLevel() {
        return requiredSkillLevel;
    }

    public void setRequiredSkillLevel(int requiredSkillLevel) {
        this.requiredSkillLevel = requiredSkillLevel;
    }
}

Here is my Roster class:

 @PlanningSolution
public class Roster {

    private List<Employee> employeeList;

    private List<Shift> shiftList;

    private HardSoftScore score;

    public Roster(List<Employee> employeeList, List<Shift> shiftList) {
        this.employeeList = employeeList;
        this.shiftList = shiftList;
    }

    @ProblemFactCollectionProperty
    @ValueRangeProvider(id="employee")
    public List<Employee> getEmployeeList() {
        return employeeList;
    }

    public void setEmployeeList(List<Employee> employeeList) {
        this.employeeList = employeeList;
    }

    @PlanningEntityCollectionProperty
    public List<Shift> getShiftList() {
        return shiftList;
    }

    public void setShiftList(List<Shift> shiftList) {
        this.shiftList = shiftList;
    }

    @PlanningScore
    public HardSoftScore getScore() {
        return score;
    }

    public void setScore(HardSoftScore score) {
        this.score = score;
    }
}

Here is my constraint provider:

    public class ConstraintProvider implements org.optaplanner.core.api.score.stream.ConstraintProvider {
    @Override
    public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
        return new Constraint[]{
                requiredSkillLevelOfEmployeesForShifts(constraintFactory)
        };
    }

    private Constraint requiredSkillLevelOfEmployeesForShifts(ConstraintFactory constraintFactory) {
        return constraintFactory.from(Shift.class)
                .groupBy(Shift::getEmployee, sum(Shift::getRequiredSkillLevel))
                .filter((employee, requiredSkillLevel) -> requiredSkillLevel > employee.getSkillLevel())
                .penalize("requiredSkillLevelForShifts",
                HardSoftScore.ONE_HARD,
                (employee, requiredSkillLevel) -> requiredSkillLevel - employee.getSkillLevel());
    }
}

Here is my controller:

@RestController
@RequestMapping("/roster")
public class RosterController {

    @Autowired
    private SolverManager<Roster, UUID> solverManager;

    @PostMapping("/solve")
    public Roster solve(@RequestBody Roster problem) {
        UUID problemId = UUID.randomUUID();
        // Submit the problem to start solving
        SolverJob<Roster, UUID> solverJob = solverManager.solve(problemId, problem);
        Roster solution;
        try {
            // Wait until the solving ends
            solution = solverJob.getFinalBestSolution();
        } catch (InterruptedException | ExecutionException e) {
            throw new IllegalStateException("Solving failed.", e);
        }
        return solution;
    }

}

The JSON data I pass via a post request is as follows:

 {
   "shifts":[
      {
         "sid":0,
         "startTime":"09:00",
         "endTime":"18:00",
         "requiredSkillLevel": 12
      },
      {
         "sid":1,
         "startTime":"12:00",
         "endTime":"20:00",
         "requiredSkillLevel": 10
      },
      {
         "sid":2,
         "startTime":"18:00",
         "endTime":"00:00",
         "requiredSkillLevel": 10
      },
      {
         "sid": 3,
         "startTime":"09:00",
         "endTime":"18:00",
         "requiredSkillLevel": 12
      },
      {
         "sid":4,
         "startTime":"12:00",
         "endTime":"20:00",
         "requiredSkillLevel": 10
      },
      {
         "sid":5,
         "startTime":"18:00",
         "endTime":"00:00",
         "requiredSkillLevel":10
      },
      {
         "sid":6,
         "startTime":"09:00",
         "endTime":"18:00",
         "requiredSkillLevel": 12
      },
      {
         "sid":7,
         "startTime":"12:00",
         "endTime":"20:00",
         "requiredSkillLevel": 10
      },
      {
         "sid":8,
         "startTime":"18:00",
         "endTime":"00:00",
         "requiredSkillLevel":10
      },
      {
         "sid":9,
         "startTime":"09:00",
         "endTime":"18:00",
         "requiredSkillLevel": 12
      },
      {
         "sid":10,
         "startTime":"12:00",
         "endTime":"20:00",
         "requiredSkillLevel": 10
      },
      {
         "sid":11,
         "startTime":"18:00",
         "endTime":"00:00",
         "requiredSkillLevel":10
      },
      {
         "sid":12,
         "startTime":"09:00",
         "endTime":"18:00",
         "requiredSkillLevel": 12
      },
      {
         "sid":13,
         "startTime":"12:00",
         "endTime":"20:00",
         "requiredSkillLevel": 10
      },
      {
         "sid":14,
         "startTime":"18:00",
         "endTime":"00:00",
         "requiredSkillLevel":10
      }
   ],
   "employees":[
      {
         "eid":0,
         "name":"john",
         "skillLevel": 10
      },
      {
         "eid":1,
         "name":"elaine",
         "skillLevel": 2
      },
      {
         "eid":2,
         "name":"kieran",
         "skillLevel": 11
      },
      {
         "eid":3,
         "name":"maeve",
         "skillLevel": 10
      },
      {
         "eid":4,
         "name":"steve",
         "skillLevel": 9
      },
      {
         "eid":5,
         "name":"steve",
         "skillLevel": 9
      },
      {
         "eid":6,
         "name":"steve",
         "skillLevel": 15
      },
      {
         "eid":7,
         "name":"amy",
         "skillLevel": 11
      }
   ]
}

I should not be getting this error as I am doing an even simpler version of the cloud balancing application, if anyone can figure out where I am going wrong that would be a big help


Solution

  • Put a breakpoint in your RosterController, just before you call solverManager.solve(...). You'll see that your Roster instance has a shiftList field that is null.

    The problem is in the json unmarshalling of your input data, because of naming mismatch in your input json. Note that by default Jackson in Spring Boot ignores properties that doesn't exist, instead of fail-fasting (a design decision I've never understood). There's a property to change that behavior IIRC.