Saludos, esta es la segunda publicación sobre Spring Data JPA. La primera parte se dedicó por completo a rastrillos submarinos, así como a consejos de expertos. En esta parte, hablaremos sobre cómo afinar el marco a sus necesidades. Todos los ejemplos descritos están disponibles aquí .
Cuenta
Comencemos, tal vez, con una tarea simple y al mismo tiempo común: al cargar una entidad, es necesario descargar selectivamente su "hija". Considere un ejemplo simple:
@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; }
La entidad secundaria en nuestro ejemplo es perezosa: no queremos cargar datos innecesarios (y unirnos a otra tabla en la consulta SQL) cuando recibimos Child
. Pero en algunos casos en nuestra aplicación sabemos con certeza que necesitaremos tanto al niño como a sus padres. Si deja la entidad perezosa, obtenemos 2 solicitudes separadas. Si aplica una carga rápida eliminando FetchType.LAZY
, ambas entidades siempre se cargarán en la primera solicitud (y no queremos esto).
JPQL proporciona una buena solución lista para usar: esta es la palabra clave 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); }
Esta solicitud es simple y clara, pero tiene inconvenientes:
- en realidad
JpaRepository::findById
la lógica de JpaRepository::findById
agregando carga explícita - cada consulta descrita usando
@Query
verifica al inicio de la aplicación, lo que requiere analizar la consulta, verificar argumentos, etc. (ver org.springframework.data.jpa.repository.query.SimpleJpaQuery :: validateQuery ). Todo esto es trabajo que lleva tiempo y memoria. - El uso de este enfoque en un gran proyecto con docenas de repositorios y entidades entrelazadas (a veces con una docena de "hijas") conducirá a una explosión combinatoria.
Los recuentos vienen en nuestra ayuda:
@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; }
El gráfico en sí es fácil de describir; las dificultades comienzan cuando se usa. Spring Data JPA en su página sugiere hacerlo de esta manera (según corresponda a nuestro 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); }
Aquí vemos los mismos problemas (excepto que la solicitud por escrito se ha vuelto un poco más fácil). Puede terminarlos de una sola vez con la ayuda de un ajuste fino. Cree su propia interfaz, que usaremos para construir repositorios en lugar del JpaRepository
caja:
@NoRepositoryBean public interface BaseJpaRepository<T, ID extends Serializable> extends JpaRepository<T, ID> { T findById(ID id, String graphName); }
Ahora implementación:
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!");
Ahora obligue a Spring a usar BaseJpaRepositoryImpl
como base para todos los repositorios de nuestra aplicación:
@EnableJpaRepositories(repositoryBaseClass = BaseJpaRepositoryImpl.class) public class AppConfig { }
Ahora nuestro método estará disponible en todos los repositorios heredados de nuestro BaseJpaRepository
.
Este enfoque tiene un inconveniente que puede poner a un cerdo muy gordo.
Intenta pensar por ti mismoEl problema es que Hibernate (al menos al momento de escribir) no coincide con los nombres de los gráficos y los gráficos en sí. Debido a esto, puede haber un error de tiempo de ejecución cuando ejecutamos algo como
Optional<MyEntity> entity = repository.findById(id, NON_EXISTING_GRAPH);
Puede verificar el estado de la solución usando la prueba:
@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); } }
Cuando los arboles eran grandes
Y éramos pequeños e inexpertos, a menudo teníamos 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 recoge la solicitud pieza por pieza. Las desventajas de este enfoque son obvias:
- mucho que ver con tus manos
- donde hay trabajo manual, hay errores
- sin resaltado de sintaxis (errores tipográficos emergentes en tiempo de ejecución)
- es muy difícil extender y mantener el código
Un poco más tarde, apareció la API Criteria, que nos permitió exprimir un poco el código anterior:
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(); }
Usar criterios tiene varias ventajas:
- la capacidad de usar un metamodelo en lugar de valores "cableados" como "fecha"
- algunos errores en la construcción de la solicitud resultan ser errores de compilación, es decir, ya se detectaron al escribir
- el código es más corto y más inteligible que con cadenas estúpidas
También hay desventajas:
- el código es lo suficientemente complicado como para entender
- Para aprender a escribir tales consultas, debe llenar su mano (recuerdo el dolor más salvaje cuando tuve que lidiar con la corrección de errores en tales consultas, que a veces consta de 100-150 líneas con ramificación, etc.)
- una consulta compleja es bastante engorrosa (50 líneas están lejos del límite)
Quiero desarrollarlo fácilmente y con placer, por lo que no me gustan estos dos métodos.
Pasemos a la entidad ya examinada por nosotros:
@Entity public class Child { @Id private Long id; @JoinColumn(name = "parent_id") @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) private Parent parent;
Me gustaría poder cargar una entidad en diferentes modos (y sus combinaciones):
- cargar (o no) padre
- cargar (o no) juguetes
- ordenar niños por edad
Si resuelve este problema de frente, es decir, escribiendo muchas consultas que corresponden al modo de carga seleccionado, muy rápidamente esto conducirá a una explosión combinatoria:
@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();
Hay una manera simple y elegante de resolver este problema: una combinación de SQL / HQL y motores de plantillas. Se utilizó "Freemarker" en mis proyectos, aunque se pueden utilizar otras soluciones ("Timlif", "Mustash", etc.).
Comencemos a crear. En primer lugar, debemos describir la consulta en un archivo que recibe la extensión *.hql.ftl
o *.sql.ftl
(si usa SQL "puro"):
Ahora necesitas un controlador:
@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 Llegando al repositorio. Obviamente, la interfaz que hereda JpaRepository
no nos conviene. En cambio, aprovecharemos la oportunidad para crear nuestros propios repositorios:
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 que el método findUsingTemplate
accesible desde ChildRepository
debe hacer esto:
public interface ChildRepository extends BaseJpaRepository<Child, Long>, ChildRepositoryCustom {
Una característica importante asociada con el nombre.Spring unirá nuestra clase e interfaces juntas solo con el nombre correcto:
- Childrepository
- ChildRepository Custom
- ChildRepository Impl
Recuerde esto, porque en caso de un error en el nombre, se generará una excepción ininteligible, de la cual es imposible comprender la causa del error.
Ahora, utilizando este enfoque, puede resolver problemas más complejos. Supongamos que necesitamos hacer una selección basada en las características seleccionadas por el usuario. En otras palabras, si el usuario no especificó las fechas "desde" y "hasta", entonces no habrá tiempo de filtrado. Si solo se especifica la fecha "desde" o solo la fecha "hasta", el filtrado será unidireccional. Si se especifican ambas fechas, solo los registros entre las fechas especificadas se incluirán en la selección:
@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(); }
Ahora la plantilla:
<
Oracle y nvl
Considera la esencia:
@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; }
Esta entidad se utiliza en la consulta (DBMS, como recordamos, tenemos Oracle):
@Query("select nvl(record.fxRate, record.avgRate) " + " from DailyRecord record " + "where record.currency = :currency") BigDecimal findRateByCurrency(@Param("currency") String currency);
Esta es una solicitud válida que funciona. Pero hay un pequeño problema con él, que los expertos de SQL probablemente señalarán. El hecho es que nvl
en Oracle no es perezoso. En otras palabras, cuando llamamos al método findRateByCurrency
, se findRateByCurrency
registro de 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 = ?
Incluso si el valor dr.fixed_rate
presente, el DBMS aún calcula el valor devuelto por la segunda expresión en nvl
, que en nuestro caso
select avg(r.record_rate) from daily_record r where r.currency = dr.currency)
El lector probablemente ya sabe cómo esquivar el peso innecesario de la solicitud: por supuesto, esta es la nvl
palabras clave, que se compara favorablemente con nvl
su pereza, así como la capacidad de aceptar más de 2 expresiones. Vamos a corregir nuestra solicitud:
@Query("select coalesce(record.fxRate, record.avgRate) " + " from DailyRecord record " + "where record.currency = :currency") BigDecimal findRateByCurrency(@Param("currency") String currency);
Y luego, como dicen, 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 = ?
La solicitud se mantuvo igual. Esto se debe a que el dialecto oráculo de la caja se convierte en una cadena nvl
.
ObservaciónSi desea reproducir este comportamiento, elimine la segunda línea en el constructor de la clase CustomOracleDialect y ejecute la DailyRecordRepositoryTest::findRateByCurrency
Para esquivar esto, necesita crear su propio dialecto y usarlo en la aplicación:
public class CustomOracleDialect extends Oracle12cDialect { public CustomOracleDialect() { super(); registerFunction("coalesce", new StandardSQLFunction("coalesce")); } }
Sí, eso es muy simple. Ahora vinculamos el dialecto creado a la aplicación:
spring: jpa: database-platform: com.luxoft.logeek.config.CustomOracleDialect
Otra forma (en desuso): spring: jpa: properties: hibernate.dialect: com.luxoft.logeek.config.CustomOracleDialect
La ejecución repetida de la solicitud le da al codiciado koalesk:
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 y solicitudes de página
En general, la finalización de un dialecto brinda muchas oportunidades para la manipulación de consultas. A menudo, cuando se desarrolla una aplicación y una cara web, se encuentra la tarea de cargar datos en la página. En otras palabras, tenemos varios cientos de miles de registros en la base de datos, pero se muestran en paquetes de 10/50/100 registros por página. La fecha de primavera lista para usar le brinda al desarrollador una funcionalidad similar:
@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);
Este enfoque tiene un inconveniente importante, a saber, la ejecución de dos consultas, la primera de las cuales obtiene los datos, y la segunda: determina su número total en la base de datos (esto es necesario para mostrar la cantidad total de datos en el objeto de la Page
). En nuestro caso, una llamada a este método da las siguientes solicitudes (registro utilizando 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 = ''
Si la consulta es pesada (muchas tablas están unidas por una columna no indexable, solo muchas uniones, una condición de selección difícil, etc.), entonces esto puede convertirse en un problema. Pero como tenemos Oracle, puede usar la pseudocolumna rownum con una sola solicitud.
Para hacer esto, necesitamos terminar nuestro dialecto y describir la función utilizada para contar todos los 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 () "; } }
Ahora escriba una nueva consulta (en la clase 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); }
Al llamar a este código, se ejecutará una solicitud
select * from (select c.id, c.age, count(c.id) over ()
Usando el count(c.id) over ()
expresiones count(c.id) over ()
puede obtener la cantidad total de datos y obtenerla de la clase de datos para pasarla al constructor PageImpl
. Hay una manera de hacerlo de manera más elegante, sin agregar otro campo a la clase de datos, considérelo tarea :) Puede probar la solución usando la prueba ProjectionVsDataTest .
Errores de personalización
Tenemos un proyecto genial con Oracle y Spring Date. Nuestra tarea es mejorar el rendimiento de dicho código:
List<Long> ids = getIds(); ids.stream() .map(repository::findById) .filter(Optional::isPresent) .map(Optional::get) .forEach(this::sendToSchool);
La desventaja radica en la superficie: la cantidad de consultas a la base de datos es igual a la cantidad de claves únicas. Hay un método conocido para superar esta dificultad:
List<Long> ids = getIds(); repository .findAllById(ids) .forEach(this::sendToSchool);
La ventaja del muestreo múltiple es obvia: si antes teníamos muchas consultas similares del formulario
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
entonces ahora se han derrumbado a uno
select p.* from Pupil p where p.id in (1, 2, 3, ... )
Parece ser más fácil y se volvió bueno. El proyecto crece, se desarrolla, los datos se multiplican y una vez que llega lo inevitable:
Aki trueno en el cielo despejado ERROR - ORA-01795: maximum number of expressions in a list is 1000
Necesitamos buscar una salida nuevamente (no volver a la versión anterior). Dado que Oracle no le permite alimentar más de 1000 claves, puede dividir todo el conjunto de datos en partes iguales de no más de 1000 y ejecutar una cantidad múltiple de consultas:
List<List<Long>> idChunks = cgccLists.partition(ids, 1000);
Este método funciona, pero huele un poco (¿verdad?): Si encuentra dificultades en otros lugares, debe cercar el mismo jardín. Intentemos resolver el problema de manera más elegante, es decir, BaseJpaRepositoryImpl
. La forma más fácil de hacer esto es transferir la lógica anterior hacia adentro, ocultándola al usuario:
@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()); }
Se mejoró: en primer lugar, limpiamos el código de trabajo de las capas de infraestructura y, en segundo lugar, ampliamos el alcance de nuestra solución a todos los repositorios de proyectos que extienden BaseJpaRepository
. También hay desventajas. La principal es varias solicitudes en lugar de una, y también (se deriva de la principal): la necesidad de filtrar las claves, porque si esto no se hace, entonces la misma clave puede aparecer en diferentes idChunks
. Esto, a su vez, significa que la misma entidad se incluirá en la lista dos veces y, en consecuencia, se procesará dos veces. No necesitamos esto, así que aquí hay otra solución más 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); }
Utiliza la API Criteria, que permite construir una consulta final del formulario.
select p.* from Pupil p where p.id in (1, 2, ... , 1000) or p.id in (1001, ... , 2000) or p.id in (2001, ... , 3000)
Hay una sutileza: una solicitud similar se puede ejecutar más de lo habitual debido a las condiciones engorrosas, por lo que el primer método puede (a veces) ser preferible.
Eso es todo, espero que los ejemplos te hayan sido útiles y útiles en el desarrollo diario. Comentarios y adiciones son bienvenidos.