I've been developing a SpringBoot application, and some entities of the project has OneToMany relationship, which has cascade all, for example:
@OneToMany(mappedBy = "candidate", cascade = CascadeType.ALL, orphanRemoval = true)
@LazyCollection(LazyCollectionOption.FALSE)
private Set<CandidateAcademicEducation> academicEducations;
@OneToMany(mappedBy = "candidate", cascade = CascadeType.ALL, orphanRemoval = true)
@LazyCollection(LazyCollectionOption.FALSE)
private Set<CandidateProfessionalExperience> professionalExperiences;
In all entities, this cascade was working just fine, i was sending the parent object with all related entities (Using JSON), it was being saved, updated and deleted correctly, but specifically on this CandidateAcademicEducation entity, when i update the Candidate entity (Sending the same data present on database to the application, including related entities), for some reason (When the candidate already has academicEducations on database), always one of the AcademicEducations loses candidate's id (If i send 1 academicEducation, it just loses candidate's id. If i send 3, 2 of them stay with parent's id and the other loses).
I've been reading and re-reading the code a lot, because CandidateProfessionalExperience is working just fine, and i can't find the difference between them.
I've verified preUpdate and postUpdate methods on Candidate entity, and it always has the correct amount of AcademicEducation, with the data and reference to Candidate entity correctly setted.
EntityDTO:
public abstract class EntityDTO<Entity> implements Comparable<EntityDTO<Entity>> {
@JsonProperty(access = Access.READ_ONLY)
protected Long id;
public EntityDTO() {
super();
}
public EntityDTO(Long id) {
this.id = id;
}
public static<T, K extends EntityDTO<T>> T getParsedEntity(
K entityDTO
) {
if(entityDTO == null) return null;
return entityDTO.toEntity();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public int compareTo(EntityDTO<Entity> entityDTO) {
if(entityDTO == null) return -1;
boolean idIsNull = id == null;
boolean receivedIdIsNull = entityDTO.getId() == null;
if(idIsNull && receivedIdIsNull) return 0;
if(idIsNull || receivedIdIsNull) return -1;
return id.compareTo(entityDTO.getId());
}
public boolean equals(EntityDTO<Entity> entityDTO) {
return this.compareTo(entityDTO) == 0;
}
public abstract Entity toEntity();
}
Candidate entity:
@Entity
@Table(name = "candidates")
public class Candidate {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String photo;
private String profession;
private String phoneNumber;
private String linkedin;
private String birthDate;
private boolean hasProfessionalExperience;
@ManyToOne
private State state;
@ManyToOne
private City city;
@OneToOne
private User user;
@OneToMany(mappedBy = "candidate", cascade = CascadeType.ALL, orphanRemoval = true)
@LazyCollection(LazyCollectionOption.FALSE)
private Set<CandidateAcademicEducation> academicEducations;
@OneToMany(mappedBy = "candidate", cascade = CascadeType.ALL, orphanRemoval = true)
@LazyCollection(LazyCollectionOption.FALSE)
private Set<CandidateProfessionalExperience> professionalExperiences;
@CreationTimestamp
@Column(updatable = false)
private Date createdAt;
@UpdateTimestamp
private Date updatedAt;
public Candidate() {
super();
}
public Candidate(
Long id, String photo, String profession,
String phoneNumber, String linkedin, String birthDate,
boolean hasProfessionalExperience, State state,
City city, User user, Set<CandidateAcademicEducation> academicEducations,
Set<CandidateProfessionalExperience> professionalExperiences,
Date createdAt, Date updatedAt
) {
this.id = id;
this.photo = photo;
this.profession = profession;
this.phoneNumber = phoneNumber;
this.linkedin = linkedin;
this.birthDate = birthDate;
this.hasProfessionalExperience = hasProfessionalExperience;
this.state = state;
this.city = city;
this.user = user;
this.academicEducations = academicEducations;
this.professionalExperiences = professionalExperiences;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
@PrePersist
@PreUpdate
private void prePersistAndUpdate() {
academicEducations.forEach(academicEducation -> academicEducation.setCandidate(this));
professionalExperiences.forEach(professionalExperience -> professionalExperience.setCandidate(this));
}
}
CandidateDTO:
public class CandidateWithAllRelatedDataDTO extends CandidateDTO {
private SortedSet<CandidateAcademicEducationDTO> academicEducations;
private SortedSet<CandidateProfessionalExperienceDTO> professionalExperiences;
public CandidateWithAllRelatedDataDTO() {
super();
}
public CandidateWithAllRelatedDataDTO(
Long id, String photo, String profession,
String phoneNumber, String linkedin,
String birthDate, Boolean hasProfessionalExperience,
StateDTO state, CityDTO city,
SortedSet<CandidateAcademicEducationDTO> academicEducations,
SortedSet<CandidateProfessionalExperienceDTO> professionalExperiences,
Long userId, Date createdAt, Date updatedAt
) {
super(
id, photo, profession, phoneNumber,
linkedin, birthDate, hasProfessionalExperience,
state, city, userId, createdAt, updatedAt
);
this.academicEducations = academicEducations;
this.professionalExperiences = professionalExperiences;
}
public static CandidateWithAllRelatedDataDTO FromEntity(Candidate candidateEntity) {
if(candidateEntity == null) return null;
CandidateWithAllRelatedDataDTO candidateWithAllRelatedDataDTO = new CandidateWithAllRelatedDataDTO();
candidateWithAllRelatedDataDTO.setId(candidateEntity.getId());
candidateWithAllRelatedDataDTO.setPhoto(candidateEntity.getPhoto());
candidateWithAllRelatedDataDTO.setProfession(candidateEntity.getProfession());
candidateWithAllRelatedDataDTO.setPhoneNumber(candidateEntity.getPhoneNumber());
candidateWithAllRelatedDataDTO.setLinkedin(candidateEntity.getLinkedin());
candidateWithAllRelatedDataDTO.setBirthDate(candidateEntity.getBirthDate());
candidateWithAllRelatedDataDTO.setHasProfessionalExperience(candidateEntity.hasProfessionalExperience());
candidateWithAllRelatedDataDTO.setState(StateDTO.FromEntity(candidateEntity.getState()));
candidateWithAllRelatedDataDTO.setCity(CityDTO.FromEntity(candidateEntity.getCity()));
candidateWithAllRelatedDataDTO.setUserId(candidateEntity.getUser().getId());
candidateWithAllRelatedDataDTO.setCreatedAt(candidateEntity.getCreatedAt());
candidateWithAllRelatedDataDTO.setUpdatedAt(candidateEntity.getUpdatedAt());
candidateWithAllRelatedDataDTO.setAcademicEducations(
candidateEntity.getAcademicEducations().stream().map(
academicEducation -> CandidateAcademicEducationDTO.FromEntity(academicEducation)
).collect(Collectors.toCollection(() -> new TreeSet<>()))
);
candidateWithAllRelatedDataDTO.setProfessionalExperiences(
candidateEntity.getProfessionalExperiences().stream().map(
professionalExperience ->
CandidateProfessionalExperienceDTO.FromEntity(professionalExperience)
).collect(Collectors.toCollection(() -> new TreeSet<>()))
);
return candidateWithAllRelatedDataDTO;
}
public void update(CandidateWithAllRelatedDataDTO updatedCandidate) {
String photo = updatedCandidate.getPhoto();
if(photo != null) this.photo = photo;
String profession = updatedCandidate.getProfession();
if(profession != null) this.profession = profession;
String phoneNumber = updatedCandidate.getPhoneNumber();
if(phoneNumber != null) this.phoneNumber = phoneNumber;
String linkedin = updatedCandidate.getLinkedin();
if(linkedin != null) this.linkedin = linkedin;
String birthDate = updatedCandidate.getBirthDate();
if(birthDate != null) this.birthDate = birthDate;
Boolean hasProfessionalExperience = updatedCandidate.hasProfessionalExperience();
if(hasProfessionalExperience != null) this.hasProfessionalExperience = hasProfessionalExperience;
StateDTO state = updatedCandidate.getState();
if(state != null) this.state = state;
CityDTO city = updatedCandidate.getCity();
if(city != null) this.city = city;
SortedSet<CandidateAcademicEducationDTO> academicEducations = updatedCandidate.getAcademicEducations();
if(academicEducations != null) EntitySetUpdater.updateEntity(this.academicEducations, academicEducations);
SortedSet<CandidateProfessionalExperienceDTO> professionalExperiences = updatedCandidate.getProfessionalExperiences();
if(professionalExperiences != null) EntitySetUpdater.updateEntity(this.professionalExperiences, professionalExperiences);
}
@Override
public Candidate toEntity() {
Candidate candidate = super.toEntity();
Set<CandidateAcademicEducation> parsedAcademicEducations = new HashSet<>();
if(academicEducations != null) {
parsedAcademicEducations.addAll(
academicEducations.stream().map(
academicEducation -> getParsedEntity(academicEducation)
).collect(Collectors.toSet())
);
}
candidate.setAcademicEducations(parsedAcademicEducations);
Set<CandidateProfessionalExperience> parsedProfessionalExperiences = new HashSet<>();
if(professionalExperiences != null) {
parsedProfessionalExperiences.addAll(
professionalExperiences.stream().map(
professionalExperience -> getParsedEntity(professionalExperience)
).collect(Collectors.toSet())
);
}
candidate.setProfessionalExperiences(parsedProfessionalExperiences);
return candidate;
}
}
Candidate professional experience:
@Entity
@Table(name = "candidate_professional_experiences")
public class CandidateProfessionalExperience {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String roleName;
private String companyName;
private String startDate;
private String endDate;
private boolean actualJob;
@Column(length = 1000)
private String assignmentsDescription;
@ManyToOne
private State state;
@ManyToOne
private City city;
@ManyToOne
private Candidate candidate;
@CreationTimestamp
@Column(updatable = false)
private Date createdAt;
@UpdateTimestamp
private Date updatedAt;
public CandidateProfessionalExperience() {
super();
}
public CandidateProfessionalExperience(
Long id, String roleName, String companyName,
String startDate, String endDate, boolean actualJob,
String assignmentsDescription, State state, City city,
Candidate candidate, Date createdAt, Date updatedAt
) {
this.id = id;
this.roleName = roleName;
this.companyName = companyName;
this.startDate = startDate;
this.endDate = endDate;
this.actualJob = actualJob;
this.assignmentsDescription = assignmentsDescription;
this.state = state;
this.city = city;
this.candidate = candidate;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
}
Candidate Professional Experience DTO:
public class CandidateProfessionalExperienceDTO extends EntityDTO<CandidateProfessionalExperience> {
private String roleName;
private String companyName;
private String startDate;
private String endDate;
private String assignmentsDescription;
private boolean actualJob;
private StateDTO state;
private CityDTO city;
@JsonProperty(access = Access.READ_ONLY)
private Date createdAt;
@JsonProperty(access = Access.READ_ONLY)
private Date updatedAt;
public CandidateProfessionalExperienceDTO() {
super();
}
public CandidateProfessionalExperienceDTO(
Long id, String roleName, String companyName, String startDate,
String endDate, String assignmentsDescription, boolean actualJob,
StateDTO state, CityDTO city, Date createdAt, Date updatedAt
) {
super(id);
this.roleName = roleName;
this.companyName = companyName;
this.startDate = startDate;
this.endDate = endDate;
this.assignmentsDescription = assignmentsDescription;
this.actualJob = actualJob;
this.state = state;
this.city = city;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public static CandidateProfessionalExperienceDTO FromEntity(
CandidateProfessionalExperience candidateProfessionalExperienceEntity
) {
if(candidateProfessionalExperienceEntity == null) return null;
CandidateProfessionalExperienceDTO candidateProfessionalExperienceDTO = new CandidateProfessionalExperienceDTO();
candidateProfessionalExperienceDTO.setId(candidateProfessionalExperienceEntity.getId());
candidateProfessionalExperienceDTO.setRoleName(candidateProfessionalExperienceEntity.getRoleName());
candidateProfessionalExperienceDTO.setCompanyName(candidateProfessionalExperienceEntity.getCompanyName());
candidateProfessionalExperienceDTO.setStartDate(candidateProfessionalExperienceEntity.getStartDate());
candidateProfessionalExperienceDTO.setEndDate(candidateProfessionalExperienceEntity.getEndDate());
candidateProfessionalExperienceDTO.setAssignmentsDescription(
candidateProfessionalExperienceEntity.getAssignmentsDescription()
);
candidateProfessionalExperienceDTO.setActualJob(candidateProfessionalExperienceEntity.isActualJob());
candidateProfessionalExperienceDTO.setState(StateDTO.FromEntity(candidateProfessionalExperienceEntity.getState()));
candidateProfessionalExperienceDTO.setCity(CityDTO.FromEntity(candidateProfessionalExperienceEntity.getCity()));
candidateProfessionalExperienceDTO.setCreatedAt(candidateProfessionalExperienceEntity.getCreatedAt());
candidateProfessionalExperienceDTO.setUpdatedAt(candidateProfessionalExperienceEntity.getUpdatedAt());
return candidateProfessionalExperienceDTO;
}
@Override
public CandidateProfessionalExperience toEntity() {
State parsedState = getParsedEntity(state);
City parsedCity = getParsedEntity(city);
return new CandidateProfessionalExperience(
id, roleName, companyName, startDate, endDate,
actualJob, assignmentsDescription, parsedState, parsedCity,
null, null, null
);
}
@Override
public int compareTo(EntityDTO<CandidateProfessionalExperience> candidateProfessionalExperienceDTO) {
if(candidateProfessionalExperienceDTO == null) return -1;
CandidateProfessionalExperienceDTO parsedDTO = (CandidateProfessionalExperienceDTO) candidateProfessionalExperienceDTO;
int roleNameResult = StringComparator.compareStrings(roleName, parsedDTO.getRoleName());
if(roleNameResult != 0) return roleNameResult;
return StringComparator.compareStrings(companyName, parsedDTO.getCompanyName());
}
}
Candidate Academic Education:
@Entity
@Table(name = "candidate_academic_education")
public class CandidateAcademicEducation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String institutionName;
private Integer startYear;
private Integer endYear;
private CandidateLevelOfSchooling levelOfSchooling;
@ManyToOne
private Candidate candidate;
@CreationTimestamp
@Column(updatable = false)
private Date createdAt;
@UpdateTimestamp
private Date updatedAt;
public CandidateAcademicEducation() {
super();
}
public CandidateAcademicEducation(
Long id, String title, String institutionName,
Integer startYear, Integer endYear,
CandidateLevelOfSchooling levelOfSchooling,
Candidate candidate, Date createdAt, Date updatedAt
) {
this.id = id;
this.title = title;
this.institutionName = institutionName;
this.startYear = startYear;
this.endYear = endYear;
this.levelOfSchooling = levelOfSchooling;
this.candidate = candidate;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
}
Candidate Academic Education DTO:
public class CandidateAcademicEducationDTO extends EntityDTO<CandidateAcademicEducation> {
private String title;
private String institutionName;
private Integer startYear;
private Integer endYear;
private CandidateLevelOfSchooling levelOfSchooling;
@JsonProperty(access = Access.READ_ONLY)
private Date createdAt;
@JsonProperty(access = Access.READ_ONLY)
private Date updatedAt;
public CandidateAcademicEducationDTO() {
super();
}
public CandidateAcademicEducationDTO(
Long id, String title, String institutionName,
Integer startYear, Integer endYear,
CandidateLevelOfSchooling levelOfSchooling, Date createdAt,
Date updatedAt
) {
super(id);
this.title = title;
this.institutionName = institutionName;
this.startYear = startYear;
this.endYear = endYear;
this.levelOfSchooling = levelOfSchooling;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public static CandidateAcademicEducationDTO FromEntity(
CandidateAcademicEducation candidateAcademicEducationEntity
) {
if(candidateAcademicEducationEntity == null) return null;
CandidateAcademicEducationDTO candidateAcademicEducationDTO = new CandidateAcademicEducationDTO();
candidateAcademicEducationDTO.setId(candidateAcademicEducationEntity.getId());
candidateAcademicEducationDTO.setTitle(candidateAcademicEducationEntity.getTitle());
candidateAcademicEducationDTO.setInstitutionName(candidateAcademicEducationEntity.getInstitutionName());
candidateAcademicEducationDTO.setStartYear(candidateAcademicEducationEntity.getStartYear());
candidateAcademicEducationDTO.setEndYear(candidateAcademicEducationEntity.getEndYear());
candidateAcademicEducationDTO.setLevelOfSchooling(candidateAcademicEducationEntity.getLevelOfSchooling());
candidateAcademicEducationDTO.setCreatedAt(candidateAcademicEducationEntity.getCreatedAt());
candidateAcademicEducationDTO.setUpdatedAt(candidateAcademicEducationEntity.getUpdatedAt());
return candidateAcademicEducationDTO;
}
@Override
public CandidateAcademicEducation toEntity() {
return new CandidateAcademicEducation(
id, title, institutionName, startYear, endYear,
levelOfSchooling, null, null, null
);
}
@Override
public int compareTo(EntityDTO<CandidateAcademicEducation> candidateAcademicEducationDTO) {
if(candidateAcademicEducationDTO == null) return -1;
CandidateAcademicEducationDTO parsedDTO = (CandidateAcademicEducationDTO) candidateAcademicEducationDTO;
int titleResult = StringComparator.compareStrings(title, parsedDTO.getTitle());
if(titleResult != 0) return titleResult;
return StringComparator.compareStrings(institutionName, parsedDTO.getInstitutionName());
}
}
Candidate Services (Update method with auxiliar methods):
private void validateCandidateLinkedin(String linkedin) {
boolean existsCandidateByLinkedin = candidateRepository.existsByLinkedin(
linkedin
);
if(existsCandidateByLinkedin) {
throw new ServerException(
"A candidate with this linkedin already exists",
HttpStatus.BAD_REQUEST
);
}
}
private void validateCandidatePhoneNumber(String phoneNumber) {
boolean existsCandidateByPhoneNumber = candidateRepository.existsByPhoneNumber(
phoneNumber
);
if(existsCandidateByPhoneNumber) {
throw new ServerException(
"A candidate with this phone number already exists",
HttpStatus.BAD_REQUEST
);
}
}
private void validateNewCandidateData(
Candidate databaseCandidate, CandidateWithAllRelatedDataDTO updatedCandidate
) {
boolean hasDifferentLinkedin = StringComparator.compareStrings(
databaseCandidate.getLinkedin(), updatedCandidate.getLinkedin()
) != 0;
if(hasDifferentLinkedin) validateCandidateLinkedin(updatedCandidate.getLinkedin());
boolean hasDifferentPhoneNumber = !databaseCandidate.getPhoneNumber().equals(
updatedCandidate.getPhoneNumber()
);
if(hasDifferentPhoneNumber) validateCandidatePhoneNumber(updatedCandidate.getPhoneNumber());
}
public CandidateWithAllRelatedDataDTO update(
Long userId, CandidateWithAllRelatedDataDTO updatedCandidate
) {
Candidate findedCandidate = findOneWithAllDataByUserId(
userId
);
if(findedCandidate == null) {
throw new ServerException(
"Requested user does not have a candidate account",
HttpStatus.BAD_REQUEST
);
}
CandidateWithAllRelatedDataDTO parsedUpdatedCandidate =
CandidateWithAllRelatedDataDTO.FromEntity(findedCandidate);
parsedUpdatedCandidate.update(updatedCandidate);
validateNewCandidateData(findedCandidate, parsedUpdatedCandidate);
parsedUpdatedCandidate.setUpdatedAt(new Date());
candidateRepository.save(EntityDTO.getParsedEntity(parsedUpdatedCandidate));
return findOneById(findedCandidate.getId());
}
The JSON that i've been using both in create and update methods:
{
"photo": "photo.png",
"profession": "Programador",
"phoneNumber": "(00)9.0000-0000",
"linkedin": "https://linkedin.com.br",
"birthDate": "10/2002",
"hasProfessionalExperience": true,
"state": {
"id": 1
},
"city": {
"id": 1
},
"academicEducations": [
{
"title": "Ciência da Computação",
"institutionName": "UFERSA",
"startYear": 2020,
"endYear": 2024,
"levelOfSchooling": "INCOMPLETE_HIGHER_EDUCATION"
},
{
"title": "Direito",
"institutionName": "UERN",
"startYear": 2016,
"endYear": 2020,
"levelOfSchooling": "COMPLETE_HIGHER_EDUCATION"
}
],
"professionalExperiences": [
{
"roleName": "Administrador de negócios",
"companyName": "Unijuris",
"startDate": "05/2019",
"endDate": "08/2022",
"assignmentsDescription": "Gestão de pessoas, execução de movimentações financeiras.",
"actualJob": true,
"state": {
"id": 1
},
"city": {
"id": 1
}
},
{
"roleName": "Veterinário",
"companyName": "Nova startup",
"startDate": "05/2012",
"endDate": "06/2019",
"assignmentsDescription": "Tratamento de animais de várias espécies diferentes.",
"actualJob": false,
"state": {
"id": 1
},
"city": {
"id": 1
}
}
]
}