Java EE / Jakarta EE

JPQL, a API Criteria e consultas nativas no Jakarta Persistence

Intermediário8 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

JPQL é a linguagem de consulta orientada a objetos do Jakarta Persistence: parece SQL, mas trabalha com entidades e seus atributos em vez de tabelas e colunas. Este guia percorre a linguagem desde selects simples até fetch joins, agregações, subconsultas e projeções em DTO; depois mostra a API Criteria para consultas dinâmicas, entity graphs para controlar o carregamento, atualizações em massa e SQL nativo, além das armadilhas de desempenho e de correção que cada um traz: N+1, paginação com fetch joins e contextos de persistência desatualizados.

O que você vai aprender

  • Escrever selects JPQL com joins, fetch joins, condições, ordenação, agrupamento e subconsultas
  • Retornar DTOs com expressões de construtor e paginar resultados com segurança
  • Montar consultas dinâmicas e tipadas com a API Criteria e o metamodelo estático
  • Controlar o carregamento com entity graphs e evitar selects N+1
  • Usar atualizações em massa, consultas nativas e mapeamentos de result set sem corromper o contexto de persistência

O mapeamento de entidades coloca e tira dados uma linha por vez; consultas são a forma de obter conjuntos deles. O Jakarta Persistence oferece três maneiras de fazer uma pergunta ao banco: o JPQL, uma linguagem em strings sobre o modelo de objetos; a API Criteria, a mesma linguagem montada com objetos Java; e o SQL nativo, para os casos que a abstração não cobre. As três retornam entidades managed ou valores simples pelas mesmas interfaces Query, e as três interagem com o contexto de persistência de maneiras em que é fácil errar. Os exemplos usam as entidades Order, OrderLine, Customer e Product do guia de mapeamento.

JPQL em uma página

JPQL se lê como SQL, mas cada identificador é um nome Java: nomes de entidades (Order, não orders), caminhos de atributos (o.customer.name) e literais Java. Na prática, aliases são obrigatórios; palavras-chave não diferenciam maiúsculas de minúsculas, mas identificadores diferenciam; e o resultado de um select é aquilo que você nomeia na cláusula SELECT: entidades, atributos, agregações ou objetos construídos.

TypedQuery<Order> q = em.createQuery("""
        select o
        from Order o
        join o.customer c
        where c.country = :country
          and o.status in :statuses
          and o.placedAt >= :since
        order by o.placedAt desc""", Order.class);

List<Order> recent = q.setParameter("country", "FR")
                      .setParameter("statuses", EnumSet.of(OrderStatus.NEW, OrderStatus.PAID))
                      .setParameter("since", Instant.now().minus(30, ChronoUnit.DAYS))
                      .setMaxResults(50)
                      .getResultList();

TypedQuery<T> é a versão a usar: deixa explícito o tipo do resultado e evita casts. Os parâmetros são nomeados (:country) ou posicionais (?1); os nomeados são mais claros e podem ser reutilizados dentro da consulta. Parâmetros do tipo coleção se expandem em uma lista IN. Nunca monte a string da consulta a partir da entrada do usuário: além da injeção, cada string distinta é analisada e cacheada separadamente.

A cláusula WHERE aceita os operadores SQL que você espera (=, <>, <, BETWEEN, LIKE com ESCAPE, IS NULL, IN, EXISTS), funções de string, aritméticas e de data (CONCAT, SUBSTRING, LOWER, LENGTH, SIZE, CURRENT_TIMESTAMP) e operadores específicos do modelo de objetos: IS EMPTY para coleções, MEMBER OF para pertencimento, TYPE(o) para hierarquias de herança e TREAT(o AS RushOrder) para downcast. O Persistence 3.1 adicionou LOCAL DATE, LOCAL TIME e LOCAL DATETIME para valores java.time, EXTRACT(YEAR FROM o.placedAt) e as funções numéricas CEILING, FLOOR, EXP, LN, POWER, ROUND e SIGN.

Joins e navegação por caminhos

Um caminho por uma associação monovalorada pode ser usado diretamente (o.customer.name) e implica um inner join. Associações do tipo coleção precisam de join explícito:

select distinct o
from Order o
join o.lines l
join l.product p
where p.category = 'BOOKS'

O distinct é necessário porque o join produz uma linha para cada item que atende à condição e, sem ele, o JPQL retornaria o mesmo pedido várias vezes, assim como o SQL. left join mantém os pedidos sem itens. join … on (JPA 2.1) adiciona condições ao próprio join. Fazer join com uma entidade sem associação mapeada (join Product p on p.sku = l.sku) não está na especificação 3.1, mas Hibernate e EclipseLink suportam isso como extensão, o que ajuda bastante com esquemas legados.

Fetch joins: a ferramenta contra o N+1

O loop a seguir dispara uma consulta para os pedidos e depois uma por pedido para seus itens; é o problema N+1:

for (Order o : em.createQuery("select o from Order o", Order.class).getResultList()) {
    total += o.getLines().size();     // lazy collection: SELECT ... WHERE order_id = ? each time
}

Um fetch join carrega a associação na mesma instrução e a inicializa nas entidades retornadas:

select distinct o
from Order o
left join fetch o.lines
left join fetch o.customer
where o.status = :status

No JPQL portável, fetch joins não recebem alias e não podem ser filtrados (um fetch filtrado deixaria a coleção carregada pela metade e, portanto, errada). Buscar duas coleções ao mesmo tempo gera um produto cartesiano (dez itens vezes três pagamentos dão trinta linhas por pedido), e o Hibernate se recusa a buscar bags juntos, lançando MultipleBagFetchException. Busque uma coleção por consulta, ou use coleções Set e aceite o produto quando os tamanhos forem pequenos.

Agregações, agrupamento e projeções

As agregações (COUNT, SUM, AVG, MIN, MAX) funcionam com GROUP BY e HAVING exatamente como no SQL. Resultados que não são entidades voltam como Object[], a menos que você informe um tipo. Expressões de construtor montam um DTO diretamente:

record CustomerRevenue(String name, long orders, BigDecimal revenue) { }

List<CustomerRevenue> top = em.createQuery("""
        select new shop.report.CustomerRevenue(c.name, count(o), sum(o.total.amount))
        from Order o join o.customer c
        where o.status = shop.model.OrderStatus.PAID
        group by c.name
        having sum(o.total.amount) > :minimum
        order by sum(o.total.amount) desc""", CustomerRevenue.class)
    .setParameter("minimum", new BigDecimal("10000"))
    .setMaxResults(20)
    .getResultList();

Uma consulta de projeção carrega exatamente as colunas pedidas, não cria entidades managed e não pode causar surpresas de lazy loading depois, o que faz dela o formato certo para telas somente leitura e relatórios. Repare no literal do enum: o JPQL portável exige o nome totalmente qualificado da constante, embora o Hibernate 6 também aceite a forma curta.

Subconsultas e predicados de coleção

Subconsultas são permitidas em WHERE e HAVING (where o.total.amount > (select avg(x.total.amount) from Order x)), com EXISTS, ALL, ANY e SOME. Predicados de coleção expressam muitas condições do tipo "tem pelo menos um" sem join explícito: where exists (select l from o.lines l where l.product.discontinued = true) ou, de forma mais simples, where :product member of o.products.

Named queries, resultado único e paginação

@NamedQuery associa uma consulta a uma entidade e a valida quando a unidade de persistência sobe, de modo que um erro de digitação derruba o deploy em vez da primeira requisição de um usuário:

@Entity
@NamedQuery(name = "Order.byReference",
            query = "select o from Order o where o.reference = :ref")
public class Order { ... }

Order order = em.createNamedQuery("Order.byReference", Order.class)
                .setParameter("ref", ref)
                .getSingleResult();

getSingleResult lança NoResultException quando não há linhas e NonUniqueResultException quando há mais de uma; as duas são exceções de runtime e, dentro de uma transação gerenciada pelo contêiner, a primeira não é por si só uma condição de rollback, mas muitas vezes se propaga como uma. getResultStream retorna um Stream que alguns providers implementam com um cursor rolável: feche-o. Para uma linha que pode não existir, getResultList().stream().findFirst() evita a exceção; a próxima versão da especificação adiciona getSingleResultOrNull exatamente para isso.

Paginação é setFirstResult(offset) mais setMaxResults(size), traduzidos para o LIMIT/OFFSET do banco ou equivalente. Duas regras: sempre adicione um ORDER BY determinístico (uma página sem ordem total retorna linhas diferentes a cada execução) e nunca combine paginação por offset com fetch join de coleção, pelo motivo explicado no FAQ. Para páginas profundas, a paginação por chave (keyset, where o.placedAt < :lastSeen order by o.placedAt desc) é mais rápida e continua estável diante de inserções.

Entity graphs

Fetch joins resolvem o carregamento de uma consulta específica. Entity graphs descrevem o que carregar independentemente da consulta e são passados como hint, o que combina com repositórios que expõem uma mesma consulta com vários perfis de carregamento:

@NamedEntityGraph(name = "Order.withLines",
    attributeNodes = { @NamedAttributeNode("customer"),
                       @NamedAttributeNode(value = "lines", subgraph = "lines") },
    subgraphs = @NamedSubgraph(name = "lines", attributeNodes = @NamedAttributeNode("product")))
@Entity
public class Order { ... }

EntityGraph<?> graph = em.getEntityGraph("Order.withLines");
Order order = em.find(Order.class, id, Map.of("jakarta.persistence.fetchgraph", graph));

fetchgraph carrega em eager exatamente os atributos listados e trata todo o resto como lazy; loadgraph carrega os atributos listados além do que já é eager pelo mapeamento. Os grafos funcionam com find e com consultas via setHint. Como o provider os executa (um join, vários selects) fica a critério dele; o Hibernate usa joins por padrão.

A API Criteria para consultas dinâmicas

Telas de busca com filtros opcionais são o caso clássico em que concatenar fragmentos de JPQL fica feio. A API Criteria monta a mesma consulta a partir de objetos e, com o metamodelo estático (as classes Order_, Customer_ geradas pelo annotation processor do provider), as referências a atributos são verificadas pelo compilador:

public List<Order> search(String customer, OrderStatus status, Instant since) {
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Order> cq = cb.createQuery(Order.class);
    Root<Order> o = cq.from(Order.class);
    Join<Order, Customer> c = o.join(Order_.customer);

    List<Predicate> where = new ArrayList<>();
    if (customer != null) where.add(cb.like(cb.lower(c.get(Customer_.name)), customer.toLowerCase() + "%"));
    if (status != null)   where.add(cb.equal(o.get(Order_.status), status));
    if (since != null)    where.add(cb.greaterThanOrEqualTo(o.get(Order_.placedAt), since));

    cq.select(o).where(where.toArray(Predicate[]::new))
      .orderBy(cb.desc(o.get(Order_.placedAt)));

    return em.createQuery(cq).setMaxResults(100).getResultList();
}

Tudo o que o JPQL faz (fetch joins com o.fetch(Order_.lines, JoinType.LEFT), subconsultas com cq.subquery(...), projeções por construtor com cb.construct(...), agrupamento, CriteriaUpdate e CriteriaDelete) tem uma forma em Criteria. O custo é a verbosidade: reserve Criteria para consultas realmente dinâmicas e use JPQL para o resto. Sem o metamodelo, você volta a nomes de atributos em string (o.get("status")), o que elimina a verificação de tipos que justificava a verbosidade.

Atualizações e exclusões em massa

Instruções UPDATE e DELETE em JPQL rodam como uma única instrução SQL, que é a forma eficiente de alterar muitas linhas:

int archived = em.createQuery("""
        update Order o set o.status = :archived
        where o.status = :closed and o.placedAt < :cutoff""")
    .setParameter("archived", OrderStatus.ARCHIVED)
    .setParameter("closed", OrderStatus.CLOSED)
    .setParameter("cutoff", cutoff)
    .executeUpdate();

Elas passam totalmente por fora do contexto de persistência: entidades já carregadas não são atualizadas, @Version não é incrementado a menos que você faça isso na instrução, cascatas e orphanRemoval não rodam e callbacks de ciclo de vida não são chamados. Execute as instruções em massa antes de carregar entidades na mesma transação, ou chame em.clear() depois. Elas também exigem uma transação ativa.

SQL nativo

Quando o JPQL não consegue expressar a consulta (window functions, CTEs recursivas, operadores JSON específicos do banco, hints para o otimizador), use createNativeQuery. O resultado pode ser mapeado para uma entidade, para escalares ou para um DTO via @SqlResultSetMapping:

@SqlResultSetMapping(name = "DailyRevenue",
    classes = @ConstructorResult(targetClass = DailyRevenue.class,
        columns = { @ColumnResult(name = "day", type = LocalDate.class),
                    @ColumnResult(name = "revenue", type = BigDecimal.class) }))

List<DailyRevenue> series = em.createNativeQuery("""
        select date_trunc('day', placed_at)::date as day,
               sum(total_amount) over (order by date_trunc('day', placed_at)) as revenue
        from orders where status = 'PAID'
        group by 1 order by 1""", "DailyRevenue")
    .getResultList();

Consultas nativas que retornam entidades produzem instâncias managed como qualquer outra, então valem as mesmas regras de lazy loading. O que você perde é a portabilidade entre bancos e o conhecimento que o provider tem do mapeamento: os nomes de colunas no SQL precisam bater com o esquema, não com a entidade. Stored procedures têm API própria (@NamedStoredProcedureQuery, createStoredProcedureQuery), com parâmetros IN, OUT e de cursor.

Hints, timeouts e locks

setHint repassa hints padrão ou do provider: jakarta.persistence.query.timeout (em milissegundos), jakarta.persistence.lock.timeout, os hints de entity graph vistos acima e os org.hibernate.readOnly ou org.hibernate.fetchSize do Hibernate. setLockMode(LockModeType.PESSIMISTIC_WRITE) transforma o select em SELECT … FOR UPDATE, mantendo as linhas travadas até o commit; LockModeType.OPTIMISTIC_FORCE_INCREMENT incrementa a versão de um pai quando você altera os filhos, que é como fazer a versão de um agregado cobrir suas partes. O Hibernate também oferece cache de segundo nível e cache de consultas, ambos desligados por padrão e que só valem a pena para dados de referência que mudam raramente.

Erros comuns

  • Selects N+1. Percorrer resultados tocando associações lazy. Fetch join, entity graph ou projeção.
  • Paginação por offset com fetch join de coleção. O result set inteiro em memória. Pagine a raiz e depois busque.
  • Buscar dois bags ao mesmo tempo. MultipleBagFetchException ou explosão cartesiana. Uma coleção por consulta.
  • Esquecer o distinct em joins de coleção. Entidades raiz duplicadas na lista.
  • Atualização em massa sem clear(). Entidades desatualizadas e verificações de versão ignoradas. Limpe o contexto ou rode a instrução em massa primeiro.
  • getSingleResult para linhas opcionais. NoResultException no caminho feliz. Use getResultList e findFirst.
  • JPQL montado com strings a partir da entrada do usuário. Injeção e cache de planos de consulta inundado. Parâmetros e Criteria.
  • ORDER BY omitido em consultas paginadas. Linhas se repetem ou somem entre páginas. Ordene sempre por uma chave única.

Principais pontos

  • JPQL nomeia entidades e atributos, não tabelas e colunas; use TypedQuery e parâmetros nomeados, e nunca concatene entrada do usuário.
  • Coleções exigem joins explícitos; distinct remove raízes duplicadas; JOIN FETCH carrega associações na mesma instrução e é o principal remédio contra o N+1.
  • Expressões de construtor retornam DTOs sem criar entidades managed, que é o que telas somente leitura devem usar.
  • Entity graphs descrevem o carregamento independentemente da consulta e são passados como hints fetchgraph ou loadgraph.
  • Use a API Criteria com o metamodelo gerado para filtros dinâmicos; use JPQL para tudo o que é estático.
  • UPDATE/DELETE em massa passam por fora do contexto de persistência (limpe-o depois), e o SQL nativo é a válvula de escape para recursos específicos do banco, com @SqlResultSetMapping para dar forma aos resultados.
  • Pagine com uma ordem determinística e nunca junto com um fetch join de coleção.

Fontes e leituras complementares

Perguntas frequentes

Qual é a diferença entre JOIN e JOIN FETCH?
Um JOIN simples permite filtrar ou projetar sobre a entidade associada, mas não a carrega no resultado: a associação continua lazy. O JOIN FETCH carrega a entidade ou coleção associada na mesma instrução SQL e a inicializa nas entidades retornadas, e é assim que você evita selects N+1 para um padrão de acesso conhecido.
Por que minha consulta paginada com fetch join carrega tudo?
Um fetch join de coleção multiplica as linhas: um pedido com cinco itens vira cinco linhas. O provider não consegue aplicar LIMIT/OFFSET a essas linhas sem partir uma entidade ao meio, então o Hibernate aplica a paginação em memória depois de buscar o result set inteiro e registra um aviso no log. Pagine primeiro pela entidade raiz e depois carregue as coleções daquela página com uma segunda consulta ou um entity graph.
Devo usar JPQL, Criteria ou SQL nativo?
JPQL para consultas conhecidas em tempo de desenvolvimento: é legível e, se você usar named queries, é validada quando a unidade de persistência sobe. Criteria para consultas montadas em tempo de execução a partir de filtros opcionais, em que concatenar strings seria frágil. SQL nativo quando você precisa de um recurso do banco que o JPQL não expressa (window functions, CTEs recursivas, hints específicos do fornecedor) ou para consultas de relatório que não deveriam criar entidade nenhuma.
Um UPDATE em massa em JPQL atualiza as entidades que já carreguei?
Não. UPDATE e DELETE em massa vão direto ao banco, passando por fora do contexto de persistência, então as entidades managed mantêm o estado antigo e a versão delas não é incrementada. Execute as instruções em massa no início da transação, ou chame clear() depois e recarregue o que precisar.