Saudações, este é o segundo post sobre o Spring Data JPA. A primeira parte foi inteiramente dedicada a ancinhos subaquáticos, bem como dicas de experientes. Nesta parte, falaremos sobre como aprimorar a estrutura para suas necessidades. Todos os exemplos descritos estão disponíveis aqui .
Conta
Vamos começar, talvez, com uma tarefa simples e ao mesmo tempo comum: ao carregar uma entidade, é necessário baixar seletivamente sua "filha". Considere um exemplo simples:
@Entity public class Child { @Id private Long id; @JoinColumn(name = "parent_id") @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) private Parent parent; } @Entity public class Parent { @Id private Long id; }
A entidade filho em nosso exemplo é preguiçosa: não queremos carregar dados desnecessários (e ingressar em outra tabela na consulta SQL) ao receber um Child
. Mas, em alguns casos, em nosso aplicativo, sabemos com certeza que precisaremos da criança e de seus pais. Se você deixar a entidade preguiçosa, receberemos 2 solicitações separadas. Se você aplicar o carregamento rápido removendo FetchType.LAZY
, as duas entidades sempre serão carregadas na primeira solicitação (e não queremos isso).
O JPQL fornece uma boa solução pronta para uso - esta é a palavra-chave fetch
:
public interface ChildRepository extends JpaRepository<Child, Long> { @Query("select c from Child c join fetch c.parent where c.id = :id") Child findByIdFetchParent(@Param("id") Long id); }
Essa solicitação é simples e clara, mas tem desvantagens:
- na verdade duplicamos a lógica do
JpaRepository::findById
adicionando carregamento explícito - cada consulta descrita usando
@Query
verificada na inicialização do aplicativo, o que requer analisar a consulta, verificar argumentos etc. (consulte org.springframework.data.jpa.repository.query.SimpleJpaQuery :: validateQuery ). Tudo isso é trabalho que leva tempo e memória. - o uso dessa abordagem em um grande projeto com dezenas de repositórios e entidades entrelaçadas (às vezes com uma dúzia de "filhas") levará a uma explosão combinatória.
Contagens vêm em nosso auxílio:
@Entity @NamedEntityGraphs(value = { @NamedEntityGraph( name = Child.PARENT, attributeNodes = @NamedAttributeNode("parent") ) }) public class Child { public static final String PARENT = "Child[parent]"; @Id private Long id; @JoinColumn(name = "parent_id") @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) private Parent parent; }
O gráfico em si é fácil de descrever; as dificuldades começam ao usá-lo. O Spring Data JPA em sua página sugere fazê-lo desta maneira (conforme aplicável ao nosso caso):
public interface GroupRepository extends JpaRepository<GroupInfo, String> { @EntityGraph(value = Child.PARENT) @Query("select c from Child c where c.id = :id") Child findByIdFetchParent(@Param("id") Long id); }
Aqui vemos todos os mesmos problemas (exceto que a solicitação por escrito se tornou um pouco mais fácil). Você pode finalizá-los de uma só vez com a ajuda do ajuste fino. Crie sua própria interface, que usaremos para construir repositórios em vez do JpaRepository
a box:
@NoRepositoryBean public interface BaseJpaRepository<T, ID extends Serializable> extends JpaRepository<T, ID> { T findById(ID id, String graphName); }
Agora implementação:
public class BaseJpaRepositoryImpl<T, ID extends Serializable> extends SimpleJpaRepository<T, ID> implements BaseJpaRepository<T, ID> { private final JpaEntityInformation<T, ?> entityInfo; private final EntityManager entityManager; public BaseJpaRepositoryImpl(JpaEntityInformation<T, ?> ei, EntityManager em) { super(ei, em); this.entityInfo = ei; this.entityManager = em; } @Override public T findById(ID id, String graphName) { Assert.notNull(id, "The given id must not be null!");
Agora obrigue o Spring a usar BaseJpaRepositoryImpl
como base para todos os repositórios de nosso aplicativo:
@EnableJpaRepositories(repositoryBaseClass = BaseJpaRepositoryImpl.class) public class AppConfig { }
Agora, nosso método estará disponível em todos os repositórios herdados de nosso BaseJpaRepository
.
Essa abordagem tem uma desvantagem que pode colocar um porco muito gordo.
Tente pensar por si mesmoO problema é que o Hibernate (pelo menos no momento da redação) não corresponde aos nomes dos gráficos e dos próprios gráficos. Por isso, pode haver um erro de tempo de execução quando executamos algo como
Optional<MyEntity> entity = repository.findById(id, NON_EXISTING_GRAPH);
Você pode verificar a saúde da solução usando o teste:
@Sql("/ChildRepositoryGraphTest.sql") public class ChildRepositoryGraphTest extends TestBase { private final Long childId = 1L; @Test public void testGraph_expectFieldInitialized() { Child child1 = childRepository.findOne(childId, Child.PARENT); boolean initialized = Hibernate.isInitialized(child1.getParent()); assertTrue(initialized); } @Test public void testGraph_expectFieldNotInitialized() { Child child1 = childRepository .findById(childId) .orElseThrow(NullPointerException::new); boolean initialized = Hibernate.isInitialized(child1.getParent()); assertFalse(initialized); } }
Quando as árvores eram grandes
E éramos pequenos e inexperientes, muitas vezes tivemos que ver este código:
public List<DailyRecord> findBetweenDates(Date from, Date to) { StringBuilder query = new StringBuilder("from Record "); if (from != null) { query.append(" where date >=").append(format(from)).append(" "); } if (to != null) { if (from == null) { query.append(" where date <= " + format(to) + " "); } else { query.append(" and date <= " + format(to) + " "); } } return em.createQuery(query.toString(), DailyRecord.class).getResultList(); }
Este código coleta a solicitação peça por peça. As desvantagens dessa abordagem são óbvias:
- muito a ver com suas mãos
- onde há trabalho manual - há erros
- sem realce de sintaxe (erros de digitação aparecem em tempo de execução)
- é muito difícil estender e manter o código
Um pouco mais tarde, a API Criteria apareceu, o que nos permitiu espremer um pouco o código acima:
public List<DailyRecord> findBetweenDates(Date from, Date to) { Criteria criteria = em .unwrap(Session.class) .createCriteria(DailyRecord.class); if (from != null) { criteria.add(Expression.ge("date", from)); } if (to != null) { criteria.add(Expression.le("date", to)); } return criteria.list(); }
O uso de critérios tem várias vantagens:
- a capacidade de usar um metamodelo em vez de valores "com fio", como "data"
- alguns erros na construção da solicitação acabam sendo erros de compilação, ou seja, eles já são detectados ao gravar
- o código é mais curto e mais inteligível do que com cadeias de colar estúpidas
Há também desvantagens:
- o código é complicado o suficiente para entender
- Para aprender a escrever essas consultas, você precisa preencher sua mão (lembro-me da dor mais intensa quando tive que lidar com a correção de erros nessas consultas, às vezes consistindo em 100-150 linhas, com ramificação etc.)
- uma consulta complexa é bastante complicada (50 linhas estão longe do limite)
Quero desenvolvê-lo facilmente e com prazer, para não gostar dos dois métodos.
Vamos voltar à entidade já examinada por nós:
@Entity public class Child { @Id private Long id; @JoinColumn(name = "parent_id") @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) private Parent parent;
Gostaria de poder carregar uma entidade em modos diferentes (e suas combinações):
- carregar (ou não) pai
- carregar (ou não) brinquedos
- classificar crianças por idade
Se você resolver esse problema de frente, ou seja, escrevendo muitas consultas que correspondem ao modo de carregamento selecionado, muito rapidamente isso levará a uma explosão combinatória:
@Query("select c from Child c join fetch c.parent order by c.age") List<Child> findWithParentOrderByAge(); @Query("select c from Child c join fetch c.toys order by c.age") List<Child> findWithToysOrderByAge(); @Query("select c from Child c join fetch c.parent join fetch c.toys") List<Child> findWithParentAndToys();
Existe uma maneira simples e elegante de resolver esse problema: uma combinação de SQL / HQL e mecanismos de modelo. "Freemarker" foi usado em meus projetos, embora outras soluções possam ser usadas ("Timlif", "Mustash" etc.).
Vamos começar a criar. Primeiro, precisamos descrever a consulta em um arquivo que recebe a extensão *.hql.ftl
ou *.sql.ftl
(se estiver usando SQL "puro"):
Agora você precisa de um manipulador:
@Component @RequiredArgsConstructor public class TemplateParser { private final Configuration configuration; @SneakyThrows public String prepareQuery(String templateName, Map<String, Object> params){ Template template = configuration.getTemplate(templateName); return FreeMarkerTemplateUtils.processTemplateIntoString(template, params); } }
Nada complicado. Chegando ao repositório. Obviamente, a interface que herda o JpaRepository
não nos convém. Em vez disso, aproveitaremos a oportunidade para criar nossos próprios repositórios:
public interface ChildRepositoryCustom { List<Child> findAll(boolean fetchParent, boolean fetchToys, boolean order); } @RequiredArgsConstructor public class ChildRepositoryImpl extends BaseDao implements ChildRepositoryCustom { private final TemplateParser templateParser; @Override public List<Child> findAll(boolean fetchParent, boolean fetchToys, boolean order) { Map<String, Object> params = new HashMap<>(); params.put("fetchParent", fetchParent); params.put("fetchToys", fetchToys); params.put("orderByAge", orderByAge); String query = templateParser.prepareQuery(BASE_CHILD_TEMPLATE.name, params); return em.createQuery(query, Child.class).getResultList(); } @RequiredArgsConstructor enum RepositoryTemplates { BASE_CHILD_TEMPLATE("BaseChildTemplate.hql.ftl"); public final String name; } }
Para tornar o método findUsingTemplate
acessível a partir de ChildRepository
faça o seguinte:
public interface ChildRepository extends BaseJpaRepository<Child, Long>, ChildRepositoryCustom {
Um recurso importante associado ao nomeO Spring ligará nossa classe e interfaces apenas com o nome correto:
- Repositório de crianças
- ChildRepository Custom
- Implemento ChildRepository
Lembre-se disso, porque, no caso de um erro no nome, uma exceção ininteligível será lançada, da qual é impossível entender a causa do erro.
Agora, usando essa abordagem, você pode resolver problemas mais complexos. Suponha que precisamos fazer uma seleção com base nas características selecionadas pelo usuário. Em outras palavras, se o usuário não especificou as datas "de" e "para", não haverá filtragem de tempo. Se apenas a data "de" ou apenas a data "para" for especificada, a filtragem será unidirecional. Se as duas datas forem especificadas, apenas os registros entre as datas especificadas cairão na seleção:
@Getter @RequiredArgsConstructor public class RequestDto { private final LocalDate from; private final LocalDate to; public boolean hasDateFrom() { return from != null; } public boolean hasDateTo() { return to != null; } } @Override public List<Child> findAll(ChildRequest request) { Map<String, Object> params = singletonMap("request", request); String query = templateParser.prepareQuery(TEMPLATE.name, params); return em.createQuery(query, Child.class).getResultList(); }
Agora o modelo:
<
Oracle e nvl
Considere a essência:
@Entity public class DailyRecord { @Id private Long id; @Column private String currency; @Column(name = "record_rate") private BigDecimal rate; @Column(name = "fixed_rate") private BigDecimal fxRate; @Setter(value = AccessLevel.PRIVATE) @Formula("select avg(r.record_rate) from daily_record r where r.currency = currency") private BigDecimal avgRate; }
Essa entidade é usada na consulta (DBMS, como lembramos, temos Oracle):
@Query("select nvl(record.fxRate, record.avgRate) " + " from DailyRecord record " + "where record.currency = :currency") BigDecimal findRateByCurrency(@Param("currency") String currency);
Esta é uma solicitação válida e de trabalho. Mas há um pequeno problema, que os especialistas em SQL provavelmente apontarão. O fato é que o nvl
no Oracle não é preguiçoso. Em outras palavras, quando chamamos o método findRateByCurrency
, o log de findRateByCurrency
será findRateByCurrency
select nvl( dr.fixed_rate, select avg(r.record_rate) from daily_record r where r.currency = dr.currency ) from daily_record dr where dr.currency = ?
Mesmo se o valor dr.fixed_rate
presente, o DBMS ainda calcula o valor retornado pela segunda expressão em nvl
, que no nosso caso
select avg(r.record_rate) from daily_record r where r.currency = dr.currency)
O leitor provavelmente já sabe como evitar o peso desnecessário da solicitação: é claro que essa é a palavra-chave coalesce
, que se compara favoravelmente com o nvl
sua preguiça, além da capacidade de aceitar mais de duas expressões. Vamos corrigir nosso pedido:
@Query("select coalesce(record.fxRate, record.avgRate) " + " from DailyRecord record " + "where record.currency = :currency") BigDecimal findRateByCurrency(@Param("currency") String currency);
E então, como eles dizem, de repente:
select nvl(dr.fixed_rate, select avg(r.record_rate) from daily_record r where r.currency = dr.currency) from daily_record dr where dr.currency = ?
O pedido permaneceu o mesmo. Isso ocorre porque o dialeto oracle da caixa se transforma em uma cadeia nvl
.
ObservaçãoSe você deseja reproduzir esse comportamento, exclua a segunda linha no construtor da classe CustomOracleDialect e execute o DailyRecordRepositoryTest::findRateByCurrency
Para evitar isso, você precisa criar seu próprio dialeto e usá-lo no aplicativo:
public class CustomOracleDialect extends Oracle12cDialect { public CustomOracleDialect() { super(); registerFunction("coalesce", new StandardSQLFunction("coalesce")); } }
Sim, isso é tão simples. Agora, amarramos o dialeto criado ao aplicativo:
spring: jpa: database-platform: com.luxoft.logeek.config.CustomOracleDialect
Outra maneira (descontinuada): spring: jpa: properties: hibernate.dialect: com.luxoft.logeek.config.CustomOracleDialect
A execução repetida da solicitação fornece ao koalesk cobiçado:
select coalesce(dr.fixed_rate, select avg(r.record_rate) from daily_record r where r.currency = dr.currency) from daily_record dr where dr.currency = ?
Oracle e solicitações de página
Em geral, a conclusão de um dialeto oferece oportunidades ricas para manipulação de consultas. Muitas vezes, ao desenvolver um aplicativo e uma face da Web, a tarefa de paginar dados é encontrada. Em outras palavras, temos várias centenas de milhares de registros no banco de dados, mas eles são exibidos em pacotes de 10/50/100 registros por página. A Data da primavera pronta para uso fornece ao desenvolvedor uma funcionalidade semelhante:
@Query("select new com.luxoft.logeek.data.BriefChildData(" + "c.id, " + "c.age " + ") from Child c " + " join c.parent p " + "where p.name = ''") Page<BriefChildData> browse(Pageable pageable);
Essa abordagem tem uma desvantagem significativa, a saber, a execução de duas consultas, a primeira das quais obtém os dados e a segunda determina seu número total no banco de dados (isso é necessário para exibir a quantidade total de dados no objeto Page
). No nosso caso, uma chamada para esse método fornece as seguintes solicitações (registro usando o p6spy):
select * from ( select c.id, c.age from child c inner join parent p on c.parent_id = p.id where p.name = '' ) where rownum <= 3; select count(c.id) from child c inner join parent p on c.parent_id = p.id where p.name = ''
Se a consulta for pesada (muitas tabelas são unidas por uma coluna não indexável, apenas muitas junções, uma condição de seleção difícil etc.), isso pode se tornar um problema. Mas como temos o Oracle, então, usando a pseudo-coluna do rownum, você pode conviver com uma solicitação.
Para fazer isso, precisamos terminar nosso dialeto e descrever a função usada para contar todos os registros:
public class CustomOracleDialect extends Oracle12cDialect { public CustomOracleDialect() { super(); registerFunction("coalesce", new StandardSQLFunction("coalesce")); registerFunction("total_count", new TotalCountFunc()); } } public class TotalCountFunc implements SQLFunction { @Override public boolean hasArguments() { return true; } @Override public boolean hasParenthesesIfNoArguments() { return true; } @Override public Type getReturnType(Type type, Mapping mapping) { return StandardBasicTypes.LONG; } @Override public String render(Type type, List arguments, SessionFactoryImplementor factory) { if (arguments.size() != 1) { throw new IllegalArgumentException("Only 1 argument acceptable"); } return " count(" + arguments.get(0) + ") over () "; } }
Agora escreva uma nova consulta (na classe ChildRepositoryImpl
):
@Override public Page<BriefChildData> browseWithTotalCount(Pageable pageable) { String query = "select " + " c.id as id," + " c.age as age, " + " total_count(c.id) as totalCount" + " from Child c " + "join c.parent p " + "where p.name = ''"; List<BriefChildData> list = em.unwrap(Session.class) .createQuery(query) .setFirstResult((int) pageable.getOffset()) .setMaxResults(pageable.getPageSize()) .setResultTransformer(Transformers.aliasToBean(BriefChildData.class)) .getResultList(); if (list.isEmpty()) { return new PageImpl(Collections.emptyList()); } long totalCount = list.get(0).getTotalCount(); return new PageImpl<>(list, pageable, totalCount); }
Ao chamar esse código, uma solicitação será executada
select * from (select c.id, c.age, count(c.id) over ()
Usando a expressão count(c.id) over ()
é possível obter a quantidade total de dados e obtê-la da classe de dados para passar para o construtor PageImpl
. Existe uma maneira de fazê-lo com mais elegância, sem adicionar outro campo à classe de dados, considere a tarefa de casa :) Você pode testar a solução usando o teste ProjectionVsDataTest .
Armadilhas da personalização
Temos um projeto interessante com Oracle e Spring Date. Nossa tarefa é melhorar o desempenho desse código:
List<Long> ids = getIds(); ids.stream() .map(repository::findById) .filter(Optional::isPresent) .map(Optional::get) .forEach(this::sendToSchool);
A desvantagem está na superfície: o número de consultas ao banco de dados é igual ao número de chaves exclusivas. Existe um método conhecido para superar essa dificuldade:
List<Long> ids = getIds(); repository .findAllById(ids) .forEach(this::sendToSchool);
A vantagem da amostragem múltipla é óbvia: se anteriormente tivéssemos muitas consultas semelhantes no formulário
select p.* from Pupil p where p.id = 1 select p.* from Pupil p where p.id = 2 select p.* from Pupil p where p.id = 3
então agora eles caíram para um
select p.* from Pupil p where p.id in (1, 2, 3, ... )
Parece ser mais fácil e ficou bom. O projeto cresce, desenvolve, multiplica dados e, quando o inevitável chegar:
Trovão de Aki no céu claro ERROR - ORA-01795: maximum number of expressions in a list is 1000
Precisamos procurar uma saída novamente (não retornamos à versão antiga). Como o Oracle não permite que ele alimente mais de 1000 chaves, você pode dividir o conjunto de dados inteiro em compartilhamentos iguais de não mais que 1000 e executar um número múltiplo de consultas:
List<List<Long>> idChunks = cgccLists.partition(ids, 1000);
Esse método funciona, mas cheira um pouco (não é?): Se você encontrar essas dificuldades em outros lugares, precisará cercar o mesmo jardim. Vamos tentar resolver o problema de maneira mais elegante, ou seja, BaseJpaRepositoryImpl
. A maneira mais fácil de fazer isso é transferir a lógica acima para dentro, ocultando-a do usuário:
@Override public List<T> findAllById(Iterable<ID> ids) { Assert.notNull(ids, "The given Iterable of Id's must not be null!"); Set<ID> idsCopy = Sets.newHashSet(ids); if (idsCopy.size() <= OracleConstants.MAX_IN_COUNT) { return super.findAllById(ids); } return findAll(idsCopy); } private List<T> findAll(Collection<ID> ids) { List<List<ID>> idChunks = Lists.partition(new ArrayList<>(ids), 1000); return idChunks .stream() .map(this::findAllById) .flatMap(List::stream) .collect(Collectors.toList()); }
Tornou-se melhor: em primeiro lugar, limpamos o código de trabalho das camadas de infraestrutura e, em segundo lugar, expandimos o escopo de nossa solução para todos os repositórios de projetos que estendem o BaseJpaRepository
. Há também desvantagens. O principal é vários pedidos em vez de um e também (deriva do principal) - a necessidade de filtrar as chaves, porque se isso não for feito, a mesma chave poderá aparecer em diferentes idChunks
. Por sua vez, isso significa que a mesma entidade será incluída na lista duas vezes e, consequentemente, será processada duas vezes. Não precisamos disso, então aqui está outra solução mais sofisticada:
@Override public List<T> findAllById(Iterable<ID> ids) { Assert.notNull(ids, "The given Iterable of Id's must not be null!"); ArrayList<ID> idsCopy = Lists.newArrayList(ids); if (idsCopy.size() <= OracleConstants.MAX_IN_COUNT) { return super.findAllById(ids); } return findAll(idsCopy); } private List<T> findAll(ArrayList<ID> ids) { CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<T> query = cb.createQuery(getDomainClass()); Root<T> from = query.from(getDomainClass()); Predicate predicate = toPredicate(cb, ids, from); query = query.select(from).where(predicate); return entityManager.createQuery(query).getResultList(); } private Predicate toPredicate(CriteriaBuilder cb, ArrayList<ID> ids, Root<T> root) { List<List<ID>> chunks = Lists.partition(ids, OracleConstants.MAX_IN_COUNT); SingularAttribute<? super T, ?> id = entityInfo.getIdAttribute(); Predicate[] predicates = chunks.stream() .map(chunk -> root.get(id).in(chunk)) .toArray(Predicate[]::new); return cb.or(predicates); }
Ele usa a API de critérios, que possibilita a criação de uma consulta final do formulário
select p.* from Pupil p where p.id in (1, 2, ... , 1000) or p.id in (1001, ... , 2000) or p.id in (2001, ... , 3000)
Há uma sutileza: uma solicitação semelhante pode ser executada por mais tempo do que o normal devido às condições complicadas; portanto, o primeiro método pode (às vezes) ser preferível.
Só isso, espero que os exemplos tenham sido úteis para você e úteis no desenvolvimento cotidiano. Comentários e adições são bem-vindos.