Search code examples
javaspring-boothibernatejpaorm

Hibernate/JPA: How to properly model unidirectional @ManyToMany relationship for CRUD operations?


I have implemented the following entities:

Teacher:

@Getter
@Setter
@ToString
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Teacher {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false, updatable = false)
    private Long id;
    private String name;
    @ManyToMany(cascade = CascadeType.MERGE)
    @JoinTable(
            name = "teacher_timeslot",
            joinColumns = @JoinColumn(name = "teacher_id"),
            inverseJoinColumns = @JoinColumn(name = "timeslot_id")
    )
    @ToString.Exclude
    private Set<Timeslot> timeslots = new HashSet<>(); //teacher preferred timeslots

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
        Teacher teacher = (Teacher) o;
        return id != null && Objects.equals(id, teacher.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}

Timeslot:

//ignore the JsonIdentity as I need it to another class (Timetable to be more specific)
@JsonIdentityInfo(scope = Timeslot.class, generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
@Getter
@Setter
@ToString
//@RequiredArgsConstructor
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Timeslot{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false, updatable = false)
    @PlanningId
    private Long id;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
        Timeslot timeslot = (Timeslot) o;
        return id != null && Objects.equals(id, timeslot.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
}

And those are the following JSON payload that I am using for saving/get a Teacher or Timeslot:

Save Timeslot:

{"id":3,"dayOfWeek":"MONDAY","startTime":"14:00:00","endTime":"16:00:00"}

Get Timeslots:

[
    {
        "id": 1,
        "dayOfWeek": "MONDAY",
        "startTime": "10:00:00",
        "endTime": "12:00:00"
    },
    {
        "id": 2,
        "dayOfWeek": "MONDAY",
        "startTime": "12:00:00",
        "endTime": "14:00:00"
    },
    {
        "id": 3,
        "dayOfWeek": "MONDAY",
        "startTime": "14:00:00",
        "endTime": "16:00:00"
    }
]

Save/Get Teacher:

([){
    "id": 1,
    "name": "Ben",
    "timeslots": [
        {
            "id": 1,
            "dayOfWeek": "MONDAY",
            "startTime": "10:00:00",
            "endTime": "12:00:00"
        },
        {
            "id": 2,
            "dayOfWeek": "MONDAY",
            "startTime": "12:00:00",
            "endTime": "14:00:00"
        }
    ]
}(])

If I want to add another Teacher:

{
    "id": 2,
    "name": "Alex",
    "timeslots": [
        {
            "id": 2,
            "dayOfWeek": "MONDAY",
            "startTime": "12:00:00",
            "endTime": "14:00:00"
        },
        {
            "id": 3,
            "dayOfWeek": "MONDAY",
            "startTime": "14:00:00",
            "endTime": "16:00:00"
        }
    ]
}

And I perform a getAllTeachers() request I receive the following:

[
    {
        "id": 1,
        "name": "Ben",
        "timeslots": [
            {
                "id": 1,
                "dayOfWeek": "MONDAY",
                "startTime": "10:00:00",
                "endTime": "12:00:00"
            },
            {
                "id": 2,
                "dayOfWeek": "MONDAY",
                "startTime": "12:00:00",
                "endTime": "14:00:00"
            }
        ]
    },
    {
        "id": 2,
        "name": "Alex",
        "timeslots": [
            2, // the problem
            {
                "id": 3,
                "dayOfWeek": "MONDAY",
                "startTime": "14:00:00",
                "endTime": "16:00:00"
            }
        ]
    }
]

instead of:

[
    {
        "id": 1,
        "name": "Ben",
        "timeslots": [
            {
                "id": 1,
                "dayOfWeek": "MONDAY",
                "startTime": "10:00:00",
                "endTime": "12:00:00"
            },
            {
                "id": 2,
                "dayOfWeek": "MONDAY",
                "startTime": "12:00:00",
                "endTime": "14:00:00"
            }
        ]
    },
    {
        "id": 2,
        "name": "Alex",
        "timeslots": [
            {
                "id": 2,
                "dayOfWeek": "MONDAY",
                "startTime": "12:00:00",
                "endTime": "14:00:00"
            },
            {
                "id": 3,
                "dayOfWeek": "MONDAY",
                "startTime": "14:00:00",
                "endTime": "16:00:00"
            }
        ]
    }
]

How can I solve this issue? What am I missing here? Basically, I want to replicate the following sequence of code while ensuring the CRUD functionality:

Timeslot wednesday12to14 = new Timeslot(nextTimeslotId++, DayOfWeek.WEDNESDAY, LocalTime.of(12, 0), LocalTime.of(14, 0));
Teacher ben =  new Teacher(nextTeacherId++, "Ben", List.of(tuesday12to14, tuesday14to16,
            tuesday16to18, tuesday18to20, tuesday20to22, wednesday12to14, wednesday14to16, wednesday18to20, wednesday20to22));

Any help will be appreciated! Feel free to edit if I didn't formulate the question correctly.

UPDATE

I tried to following things:

  1. Removed the cascade and added FetchType.Eager from @ManyToMany annotation
  2. Removed the cascade and the fetch type from @ManyToMany annotation
  3. I also tried to add jakarta @Transactional annotation on the Service for create and read methods.

None of them works.

This is my Teacher Service implementation:

@Service
@RequiredArgsConstructor
@Slf4j
public class TeacherService {

    private final TeacherRepo teacherRepo;

    public List<Teacher> getAllTeachers() {
        return teacherRepo.findAllByOrderByIdAsc();
    }

    public Teacher createTeacher(Teacher teacher) {
        return teacherRepo.save(teacher);
    }

    //update and delete methods

}

Solution

  • I managed to solve this issue by creating a TeacherDTO and TimeslotDTO and by using ModelMapper on the get Method:

    DTOs:

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    public class TimeslotDTO {
        private Long id;
        private DayOfWeek dayOfWeek;
        private LocalTime startTime;
        private LocalTime endTime;
    }
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    public class TeacherDTO {
        private Long id;
        private String name;
        private Set<TimeslotDTO> timeslots;
    }
    

    TeacherService:

    private final ModelMapper modelMapper;
    
        public List<TeacherDTO> getAllTeachers() {
            List<Teacher> retrievedTeachers = teacherRepo.findAllTeachersWithTimeslotsOrderedById();
            return retrievedTeachers.stream()
                    .map(this::convertToDTO)
                    .collect(Collectors.toList());
        }
    
    private TeacherDTO convertToDTO(Teacher teacher) {
            TeacherDTO teacherDTO = modelMapper.map(teacher, TeacherDTO.class);
            Set<TimeslotDTO> timeslotDTOs = teacher.getTimeslots().stream()
                    .map(timeslot -> modelMapper.map(timeslot, TimeslotDTO.class))
                    .collect(Collectors.toSet());
            teacherDTO.setTimeslots(timeslotDTOs);
            return teacherDTO;
        }