Alterar script sql gerado pelo SpringBoot

Eu tenho duas classes:
Category

@Data
@Entity
public class Category {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(length = 20, nullable = false)
    private String name;

    @ManyToMany
    private Set<Event> events;
}

E Event

@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;

e existe um relanciomaneto Many-To-Many entre eles gerando a tabela “events-categories” no banco. Quando eu executo esse código, o script sql que é gerado é esse:

Hibernate: create sequence hibernate_sequence start with 1 increment by 1
Hibernate: create table category (id bigint not null, name varchar(20) not null, primary key (id))
Hibernate: create table event (id bigint not null, description varchar(80) not null, event_date varbinary(255), primary key (id))
Hibernate: create table events_categories (category_id bigint not null, event_id bigint not null, primary key (category_id, event_id))
Hibernate: alter table events_categories add constraint FKb49xyn8hbwl4s58wlils10rs0 foreign key (event_id) references event
Hibernate: alter table events_categories add constraint FK6amkeoql7oef4gtqaxfmf4qah foreign key (category_id) references category

O que eu queria é que nessa parte: (criação da tabela do relacionamento)

Hibernate: create table events_categories (category_id bigint not null, event_id bigint not null, primary key (category_id, event_id))

Não fosse criada uma PRIMARY KEY (category_id, event_id) e sim um CONSTRAINT UNIQUE . E também queria colocar ON DELETE e ON UPDATE na chaves estrangeiras.
Alguém sabe como fazer unsando SpringBoot

Minha sugestão é você usar uma ferramenta de schema evolution como o Flyway ou Liquibase, criar os SQLs das migrations conforme sua necessidade!

1 curtida

Isto não é gerado pelo Spring, mas sim pelo Hibernate.

Não há necessidade de criar um constraint unique porque a chave primária composta já faz este papel. Por exemplo, digamos vc faça o seguinte insert:

INSERT INTO "events_categories" VALUES(3, 1);

Se vc tentar inserir novamente os mesmos valores, 3 e 1, vai lançar a seguinte exceção:

org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation

Sobre fazer adicionar ON UPDATE eu não consegui achar nada, acredito que não dá, mas sobre ON DELETE, o Hibernate tem uma anotação para isto:

import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;

// ...

@ManyToMany
@JoinTable(name = "events_categories", joinColumns = @JoinColumn(name = "event_id"), inverseJoinColumns = @JoinColumn(name = "category_id"))
@OnDelete(action = OnDeleteAction.CASCADE)
private Set<Category> categories;

Uma coisa que percebi fazendo testes com as suas entidades foi que estão sendo geradas 4 tabelas. Além de event, category e events_categories, tem a category_events.

Esta quarta tabela não deveria existir. Como vc está criando um relacionamento bidirecional, deve utilizar o mappedBy do ManyToMany. Ficaria assim:

@Data
@Entity
class Category {
  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  private Long id;

  @Column(length = 20, nullable = false)
  private String name;

  @ManyToMany(mappedBy = "categories")
  private Set<Event> events;
}

Então, mas, se por exemplo, eu quiser excluir uma categoria que tá sendo referenciada em Event, eu não consigo, porque essa categoria também está na tabela criada para o relacionamento. Nesse caso eu teria que, primeiro excluir da tabela do relacionamento para depois excluir da tabela categoria. E com a CONSTRAINT UNIQUE eu poderia excluir tranquilo e na tabela do relacionamento ficaria como null porque eu iria colocar o ON DELETE SET NULL

E sobre a criação de quatro tabelas, quando eu coloco o “mappedBy” eu recebo um erro ao tentar criar um novo evento


Tô completamente perdido sobre o que pode ser isso. E isso não acontece quando não coloco o mappedBy

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);
        });
  }
}

Cara, muito obrigado! Todas as dicas e as referencias ajudaram bastante. Ainda não conseguir entender tudo porque estou começando a estudar esse ambiente Spring, Hibernate, Jpa… agora, mas vou pesquisando pra me aprofundar. Mas uma coisa que queria esclarecer, não entendi essa parte:

@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);
}

O que seria esse problema n+1 e como essa parte de código vai ajudar?

E outra coisa que aconteceu quando testei os ajustes que você fez com minhas adaptações: eu estou usando essa aplicação como uma api e quando adiciono um Event ele me retorna uma repetição gigante do mesmo Event

Antes de adicionar um Event eu tenho que adicionar uma Category, e até ai beleza, eu adiciono e consigo pegar as Categories adiciondas sem problemas, mas quando eu adicionou um Event e tento listar as Categories, ocorre o mesmo problema da imagem.

No console eu tenho esse warn:

Não da pra mostrar todo porque é gigante, da pra ter noção pelo tamanho da barra de scroll

Vou colocar as classe que tenho na aplicação

EventController:

@RestController
@RequestMapping(value = "/api/events")
public class EventController {

    private final EventRepository eventRepository;
    private final CategoryRepository categoryRepository;

    public EventController(EventRepository eventRepository, CategoryRepository categoryRepository) {
        this.eventRepository = eventRepository;
        this.categoryRepository = categoryRepository;
    }

    @GetMapping
    public ResponseEntity<List<Event>> listEvents() {
        return ResponseEntity.status(HttpStatus.OK).body(eventRepository.findAll());
    }

    @PostMapping
    public ResponseEntity<Object> addEvent(@RequestBody @Valid EventDto eventDto) {

        if (eventDto.getIdsOfCategoriesToBeAdd().length == 0) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                    .body("Não é possível adicionar eventos sem categoria(s)");
        }

        Event event = new Event();

        for (int i = 0; i < eventDto.getIdsOfCategoriesToBeAdd().length; i++) {
            Optional<Category> optionalCategory = categoryRepository.findById(eventDto.getIdsOfCategoriesToBeAdd()[i]);

            Category category = optionalCategory.get();
            event.addCategory(category);
        }

        BeanUtils.copyProperties(eventDto, event);
        System.out.println(event);

        return ResponseEntity.status(HttpStatus.CREATED).body(eventRepository.save(event));
    }

    @PutMapping("/{id}")
    public ResponseEntity<Object> updateEvent(@PathVariable(value = "id") Long id,
            @RequestBody @Valid EventDto eventDto) {

        Optional<Event> eventOptional = eventRepository.findById(id);

        if (!eventOptional.isPresent()) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Evento não encontrado");
        }

        Set<Category> categories = new HashSet<>();

        for (int i = 0; i < eventDto.getIdsOfCategoriesToBeAdd().length; i++) {
            Optional<Category> optionalCategory = categoryRepository.findById(eventDto.getIdsOfCategoriesToBeAdd()[i]);

            Category category = optionalCategory.get();

            categories.add(category);
        }

        Event event = eventOptional.get();
        event.setDescription(eventDto.getDescription());
        event.setEventDates(eventDto.getEventDates());
        event.setCategories(categories);

        return ResponseEntity.status(HttpStatus.OK).body(eventRepository.save(event));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Object> deleteEvent(@PathVariable(value = "id") Long id) {

        Optional<Event> eventOptional = eventRepository.findById(id);

        if (!eventOptional.isPresent()) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Evento não encontrado");
        }

        eventRepository.delete(eventOptional.get());
        return ResponseEntity.status(HttpStatus.NO_CONTENT).body("Evento deletado com sucesso");
    }
}

EventDto:

@Data
public class EventDto {

    @NotNull
    private Long[] idsOfCategoriesToBeAdd;

    @NotBlank
    private String description;

    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate[] eventDates;
}

CategoryController:

@RestController
@RequestMapping(value = "/api/categories")
public class CategoryController {

    private final CategoryRepository categoryRepository;

    public CategoryController(CategoryRepository categoryRepository) {
        this.categoryRepository = categoryRepository;
    }

    @GetMapping
    public ResponseEntity<List<Category>> listCategories() {
        return ResponseEntity.status(HttpStatus.OK).body(categoryRepository.findAll());
    }

    @PostMapping
    public ResponseEntity<Object> addCategory(@RequestBody @Valid CategoryDto categoryDto) {
        Category category = new Category();

        BeanUtils.copyProperties(categoryDto, category);

        return ResponseEntity.status(HttpStatus.CREATED).body(categoryRepository.save(category));
    }

    @PutMapping("/{id}")
    public ResponseEntity<Object> updateCategory(@PathVariable(value = "id") Long id,
            @RequestBody @Valid CategoryDto categoryDto) {

        Optional<Category> categoryOptional = categoryRepository.findById(id);

        if (!categoryOptional.isPresent()) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Categoria não encontrada");
        }

        Category category = categoryOptional.get();

        category.setName(categoryDto.getName());

        return ResponseEntity.status(HttpStatus.CREATED).body(categoryRepository.save(category));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Object> deleteEvent(@PathVariable(value = "id") Long id) {

        Optional<Category> categoryOptional = categoryRepository.findById(id);

        if (!categoryOptional.isPresent()) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Evento não encontrado");
        }

        for (Event e : categoryOptional.get().getEvents()) {
            e.removeCategory(categoryOptional.get());
        }

        categoryOptional.get().removeAllEvents();
        categoryRepository.delete(categoryOptional.get());
        return ResponseEntity.`status(HttpStatus.NO_CONTENT).body("Evento deletado com sucesso");
    }
}

CategoryDto:

@Data
public class CategoryDto {

    @NotBlank
    private String name;

}

Fora isso só tem mais os repositories e as models que já coloquei aqui

O problema de N+1 é quando uma query adicional é executada para cada item relacionado a uma certa entidade.

Aqui explica melhor:

Por exemplo, digamos que eu tenha uma categoria chamada “Categoria 1” e há 2 eventos nesta categoria.

Usando o findById normal, olha as queries que aparecem no terminal:

Antes de ocorrer os deletes, foram feitos 4 selects.

Um select para trazer a categoria em si, mais um para trazer a associação com events_categories e mais um para cada evento associado.

Em um cenário onde há muuuitos eventos associados com uma mesma categoria, haveria uma grande quantidade de selects que poderia deixar sua aplicação lenta.

Eu não sei explicar a parte tecnica, mas com a query que eu criei, eu instruo o Hibernate a trazer minha categoria e todos os eventos associados com ela em apenas um select.

Olha o resultado no terminal quando eu uso a minha query:

Claro que num cenário onde há muuuitos eventos numa mesma categoria, vc ainda teria que lidar com os multiplos deletes, mas aí é outra história.

O problema que vc mostrou nos prints é causado por como o Jackson trabalha para transformar nosso objetos em JSON.

Se vc não fizer nada, ele meio que entra em loop porque a partir de um evento ele serializa as categorias e a partir destas categorias ele serializa os eventos…

Vc precisa instruir o Jackson a parar em algum ponto. Há várias formas de fazer isso e aqui mostra algumas:

Nos meus testes aqui, bastou adicionar @JsonIgnore no campo events da classe Category para resolver o problema, ficou assim:

@JsonIgnore
@ManyToMany(mappedBy = "categories")
private Set<Event> events = new HashSet<>();

Cara, bizarro o quanto você me ajudo! Muito obrigado!

1 curtida