Executando verificação de segurança...
2

Como eu faço um endpoint com multiplos filtros usando Spring e Hibernate (JPA)

Cenário

Se você já precisou construir um endpoint que recebe n parâmetros mas precisa que sua aplicação consulte o banco dinamicamente de acordo com os parâmetros informados e ignore aqueles que não foram informados, sabe como pode ser desafiador escolher uma boa estratégia.

Imagine que, no nosso banco de dados, temos uma tabela com a seguinte estrutura

CREATE TABLE empresa (
    cpf VARCHAR(14) NOT NULL PRIMARY KEY,
    nome VARCHAR(255) NOT NULL,
    estado VARCHAR(255) NOT NULL,
    uf VARCHAR(2) NOT NULL,
    numero_funcionarios INT NOT NULL,
    qtd_filiais INT NOT NULL
);

Nos foi solicitado que criássemos um endpoint que vai filtrar os parceiros comerciais com base nos campos CPF, nome e estado. A questão é que temos aqui 7 combinações diferentes, se consideramos que todos os parâmetro nulos é uma combinação possível (afinal talvez a gente não queira aplicar parâmetro nenhum)

Utilizando um método para cada

A primeira vez que me deparei com esse problema, a minha solução foi criar uma query JPA para cada combinação e validar quais dos parâmetros haviam sido informados. Dessa forma eu podia chamar a query específica para cada cenário. Meu código ficou mais ou menos assim:

Repository com cada combinação de filtros

public interface ParceiroRepository extends JpaRepository<ParceiroEntity, String> {

    public ParceiroEntity findByCpf(String cpf);

    public ParceiroEntity findByCpfAndNome(String cpf, String nome);

    // demais combinações abaixo
}

Service com as combinações de filtro e validação das combinações:

@Service
public class ParceiroService {

    private final ParceiroRepository repository;

    public ParceiroService(ParceiroRepository repository) {
        this.repository = repository;
    }

    public ParceiroEntity findByParams(ParceiroRequestParams params){
        if (params.getCpf() != null && params.getEstado() == null && params.getNome() == null)
            return repository.findByCpf(params.getCpf());

        if (params.getCpf() == null && params.getNome() != null && params.getEstado() != null)
            return repository.findByCpfAndNome(params.getCpf(), params.getNome());

        throw new IllegalArgumentException("Parâmetros informados não correspondem a nenhuma combinação permitida.");
    }

    public ParceiroEntity findByCpf(String cpf){
        return repository.findByCpf(cpf);
    }

    public ParceiroEntity findByCpfAndNome(String cpf, String nome){
        return repository.findByCpfAndNome(cpf, nome);
    }

    // demais combinações abaixo
}

Controller para chamar o service:

@RestController
public class ParceirosController {

    private final ParceiroService service;

    public ParceirosController(ParceiroService service) {
        this.service = service;
    }

    @GetMapping("/parceiros")
    public ResponseEntity<ParceiroEntity> getParceiros(ParceiroRequestParams params){
        return ResponseEntity.ok(service.findByParams(params));
    }
}

O problema é que, sempre que surgia um novo parâmetro, a quantidade de métodos e de validações aumentava.

Query com parâmetros dinâmicos

A implementação que mais costumo utilizar hoje em dia consiste em encarregar a minha query no banco de dados de fazer todo o tratamento necessário.

Primeiro, crio um objeto com os filtros que vou quere aplicar na minha consulta. Cada campo representa uma coluna na tabela.

public class ParceiroRequestParams {
    String cpf;
    String nome;
    String estado;
   
   // getters e setters
}

E passo esse mesmo objeto como parâmetro na minha query JPA

@Query("SELECT e FROM Empresa e " +
            "WHERE ((:#{#params.cpf} IS NULL OR :#{#params.cpf} = '') OR e.cpf = :#{#params.cpf}) " +
            "AND ((:#{#params.nome} IS NULL OR :#{#params.nome} = '') OR LOWER(e.nome) LIKE LOWER(CONCAT('%', :#{#params.nome}, '%'))) " +
            "AND ((:#{#params.estado} IS NULL OR :#{#params.estado} = '') OR LOWER(e.estado) LIKE LOWER(CONCAT('%', :#{#params.estado}, '%'))) ")
    List<ParceiroEntity> searchEmpresas(@Param("params") ParceiroRequestParams params);

No meu controller o mesmo objeto que eu informo ao banco representa os filtros do meu endpoint

@GetMapping("/parceiros")
    public ResponseEntity<List<ParceiroEntity>> getParceiros(ParceiroRequestParams params){
        return ResponseEntity.ok(service.findByParams(params));
    }

Agora, temos uma query totalmente dinâmica. Ao rodar o código, vemos que, se informarmos diferentes parâmetros, nossa API trará os valores com base nesses parâmetros, com diferentes combinações.

Como funciona

Para cada parâmetro na nossa query, primeiro verificamos se o valor é NULL (ou uma string vazia '' no caso de varchar), se o valor for nulo a condição automaticamente retorna TRUE, o que faz aquela condição seja automaticamente ignorada.

Se, por outro lado, o parâmetro tiver um valor, essa condição será considerada na busca no banco de dados.

A melhor parte é que caso seja necessário adicionar um novo filtro, basta adicionar uma nova propriedade no nosso objeto e uma nova condição na nossa query

public class ParceiroRequestParams {
    String cpf;
    String nome;
    String estado;
    String numero;

// getters e setters
}
@Query("SELECT e FROM Empresa e " +
            "WHERE ((:#{#params.cpf} IS NULL OR :#{#params.cpf} = '') OR e.cpf = :#{#params.cpf}) " +
            "AND ((:#{#params.nome} IS NULL OR :#{#params.nome} = '') OR LOWER(e.nome) LIKE LOWER(CONCAT('%', :#{#params.nome}, '%'))) " +
            "AND ((:#{#params.estado} IS NULL OR :#{#params.estado} = '') OR LOWER(e.estado) LIKE LOWER(CONCAT('%', :#{#params.estado}, '%'))) " +
            "AND ((:#{#params.numero} IS NULL) OR e.numeroFuncionarios = :#{#params.numero})")
    List<ParceiroEntity> searchEmpresas(@Param("params") ParceiroRequestParams params);

Conclusão

Concentrando toda a lógica dos filtros na query, evitamos sobrecarregar o código com validações e, ao mesmo tempo, permitimos maior flexibilidade para adicionar novos parâmetros.

Uma contrapartida desta abordagem é que a sintaxe da query acaba ficando muito complexa o que pode causar dificuldade pra quem for ler seu código.

Código da API no github: link

Mais publicações no meu blog: https://victor-vn.github.io/

Carregando publicação patrocinada...
1

Sua segunda solução ficou bem melhor do que a primeira, apesar da query ficar dificil de ler, como vc destacou.

De qualquer forma, como vc esta usando Spring Data, pode usar o Query by Example. Vc pode ler mais no link abaixo.

https://docs.spring.io/spring-data/jpa/reference/repositories/query-by-example.html

E este é um exemplo bem básico de como ficaria.

@Getter
@Setter
@Entity
@Table(name = "empresa")
class ParceiroEntity {
  @Id
  @Column(length = 14, nullable = false)
  private String cpf;

  @Column(length = 255, nullable = false)
  private String nome;

  @Column(length = 255, nullable = false)
  private String estado;

  @Column(length = 2, nullable = false)
  private String uf;

  @Column(nullable = false)
  private Integer numeroFuncionarios;

  @Column(nullable = false)
  private Integer qtdFiliais;
}

@Getter
@Setter
class ParceiroRequestParams {
  private String cpf;
  private String nome;
  private String estado;
  private Integer numero;
}

interface ParceiroRepository extends JpaRepository<ParceiroEntity, String> {
}

@RestController
class ParceirosController {
  @Autowired
  private ParceiroRepository repository;

  @GetMapping("/parceiros")
  List<ParceiroEntity> getParceiros(ParceiroRequestParams params) {
    var matcher = ExampleMatcher
      .matching()
      .withStringMatcher(StringMatcher.CONTAINING)
      .withIgnoreNullValues();

    var entity = new ParceiroEntity();
    entity.setCpf(params.getCpf());
    entity.setNome(params.getNome());
    entity.setEstado(params.getEstado());
    entity.setNumeroFuncionarios(params.getNumero());

    var example = Example.of(entity, matcher);

    return repository.findAll(example);
  }
}

A JpaRepository já estende a QueryByExampleExecutor, então não precisamos fazer nenhuma alteração no nosso repository.

Basicamente, como o nome da funcionalidade sugere, a gente cria um exemplo de entidade que queremos trazer do banco.

Funciona bastante bem para vários casos, e é perfeito para este cenário que vc apresentou.

Outra opção é usar as specifications. Veja mais aqui:

https://docs.spring.io/spring-data/jpa/reference/jpa/specifications.html

Apesar de mais complexas, specifications são bastante poderosas!