Ah, agora entendi o porquê de vc querer usar o unique.
Bom, este não é o caminho, até porque nem faria sentido ter um Event associado com nulo e mesmo assim, com unique, vc só poderia associar com nulo apenas uma vez.
Como vc está usando JPA, vc tem que buscar formas de fazer respeitando o jeito ninja do JPA de ser.
E este jeito é, primeiro remover as associações entre Event e Category, e só depois remover a Category desejada.
O problema é que como fazer isto não é tão óbvio, mas eu fiz umas pesquisas e vou compartilhar o que encontrei.
Primeiro ponto: Se vc quer uma associação bidirecional, o mappedBy é um requisito, não uma opção. Está na documentação:
https://jakarta.ee/specifications/persistence/3.0/apidocs/jakarta.persistence/jakarta/persistence/manytomany
If the relationship is bidirectional, the non-owning side must use the mappedBy element…
Este problema que vc encontrou, StackOverflowError, não é por causa do mappedBy, mas sim por causa de como o Lombok implementa os métodos toString, equals e hashCode quando vc usa @Data.
O ideal é não confiar na implementação destes métodos que o Lombok faz e implementar vc mesmo na mão. Fazendo isto vc tem total controle sobre o resultado.
Quando o assunto é entidades, eu evito usar @Data e uso as outras anotações separadamente. Ou seja, ao invés disto:
@Data
@Entity
public class Event {
Eu prefiro isto:
@Getter
@Setter
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class Event {
São mais linhas de código, mas eu deixo explícido a minha intenção.
De qualquer forma, vc pode continuar usando @Data, mas deve implementar os métodos toString, equals e hashCode na mão.
Segundo ponto: Quando vc trabalha com relacionamentos bidirecionais, precisa se preocupar em manter os 2 lados da relação sincronizados e, para isso, vc pode usar uns métodos auxiliares.
Aqui está 2 posts que sempre uso como referência, eles mostram algumas boas práticas:
Bom, dito isto, abaixo está a classe Event com as minhas modificações. Ela tem os métodos auxiliares indicados nos posts acima e, apesar de usar @Data, eu implementei os métodos toString, equals e hashCode de um jeito que faz sentido para esta entidade. equals e hashCode são importantes já que estamos trabalhando com Set e requerem cuidado na hora de implementar, pois eles tem umas regrinhas que a gente tem que seguir.
Além disso, @Entity requer ainda mais um nível de cuidado já que precisa se manter consistente ao longo de todo o ciclo de vida. Eu decidi só ignorar as relações na minha implementação, vc deve analisar seu caso e adaptar melhor.
@Data
@Entity
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(length = 80, nullable = false)
private String description;
@Column(nullable = true)
private LocalDate eventDate;
@ManyToMany
@JoinTable(name = "events_categories", joinColumns = @JoinColumn(name = "event_id"), inverseJoinColumns = @JoinColumn(name = "category_id"))
private Set<Category> categories = new HashSet<>();
public void addCategory(Category category) {
this.categories.add(category);
category.getEvents().add(this);
}
public void removeCategory(Category category) {
this.categories.remove(category);
category.getEvents().remove(this);
}
@Override
public int hashCode() {
return Objects.hash(this.description, this.eventDate);
}
@Override
public boolean equals(Object obj) {
if (obj instanceof Event) {
Event other = (Event) obj;
return Objects.equals(this.description, other.description)
&& Objects.equals(this.eventDate, other.eventDate);
}
return false;
}
@Override
public String toString() {
return String.format("Event(description=%s, date=%s)", this.description, this.eventDate);
}
}
E abaixo é como eu deixei a classe Category. Eu adicionei um método que nos auxilia a fazer as desassociações.
@Data
@Entity
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(length = 20, nullable = false, unique = true)
private String name;
@ManyToMany(mappedBy = "categories")
private Set<Event> events = new HashSet<>();
public void removeAllEvents() {
for (Event event : this.getEvents()) {
event.getCategories().remove(this);
}
}
@Override
public int hashCode() {
return this.name.hashCode();
}
@Override
public boolean equals(Object obj) {
return obj instanceof Category ? this.name.equals(((Category) obj).name) : false;
}
@Override
public String toString() {
return String.format("Category(name=%s)", this.name);
}
}
Para evitar problemas de N+1 na hora de fazer a desassociação, eu decidi criar no repository uma query especifica usando JOIN FETCH, ficou assim:
@Repository
interface CategoryRepository extends JpaRepository<Category, Long> {
@Query("FROM Category c JOIN FETCH c.events e JOIN FETCH e.categories")
Optional<Category> byId(Long id);
}
Eu achei estranho fazer aqueles 2 JOINs, mas foi o que funcionou para mim.
Por fim, abaixo está o CommandLineRunner que usei para fazer o teste. Veja como eu primeiro faço a desassociação antes de, de fato, invocar o delete.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
@Transactional
public class Runner implements CommandLineRunner {
@Autowired
private CategoryRepository repo;
@Override
public void run(String... args) throws Exception {
repo
.byId(1L)
.ifPresent(category -> {
category.removeAllEvents();
repo.delete(category);
});
}
}