Spring Data es el nombre de un módulo de Spring que a su vez engloba un gran número de sub-módulos cuyo objetivo es facilitar el acceso y explotación de datos en aplicaciones basadas en Spring siguiendo una praxis común. Los datos que se explotan pueden provenir de orígenes tan dispares como bases de datos relacionales, a través de JPA o Spring Data JDBC, bases de datos NoSQL como MongoDB o Neo4J, o directorios LDAP. El presente tutorial expondrá el uso básico del módulo para JPA.
IMPORTANTE. Este artículo está obsoleto. En su lugar, consulta mi completo curso gratuito Spring Data JPA.
Nota sobre versiones
La primera versión de este tutorial data de 2014 y utilizaba Spring 3 y Java 6. Necesitaba una revisión a fondo, así que lo he ido actualizado periódicamente y ahora mismo se utiliza Spring Data JPA 2.3.3 y Java 8. El proyecto del tutorial original se encuentra en un tag del repositorio git por si el lector tuviera que recurrir a versiones muy antiguas de Spring Data JPA.
Como nota informativa, indico que:
- Hasta Spring Data JPA 1.6.4.RELEASE se requiere Spring 3 \Java 6.
- Spring Data JPA 1.7.0.RELEASE requiere Spring 4\ Java 7
- A partir de la versión 2 ya se utiliza Spring 5 y Java 8.
Los cambios se pueden revisar en este fichero.
Proyecto para pruebas
Actualmente para la creación de un nuevo proyecto basado en el ecosistema Spring tenemos dos grandes alternativas:
- Utilizar Spring Boot (opción recomendada).
- Crear manualmente o con plantillas el proyecto, esto es, sin Spring Boot.
Aunque recomiendo la primera opción, en este tutorial voy a crear el proyecto desde cero para mostrar cómo se configuran todos los elementos necesarios para utilizar Spring Data JPA, algo totalmente automático en Spring Boot y explicado en Introducción a Spring Boot: Aplicación Web con Spring Data JPA.
Vamos a partir de un proyecto estándar Maven 3, por lo que en primer lugar procedemos a definir las dependencias que necesitamos en el pom.xml.
- Spring Data JPA. Utilizaremos la última versión estable en el momento de la última actualización del presente tutorial. En estos momentos se trata de (2.3.3.RELEASE) y que ya incluye como dependencias los módulos del core de Spring requeridos en la versión 5.2.8.RELEASE. Si necesitamos usar una versión distinta de Spring, definimos estas dependencias explícitamente y las excluimos de Spring Data JPA por ejemplo así:
<dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-jpa</artifactId> <version>${spring.data.jpa.version}</version> <exclusions> <exclusion> <artifactId>spring-orm</artifactId> <groupId>org.springframework</groupId> </exclusion> <exclusion> <artifactId>spring-tx</artifactId> <groupId>org.springframework</groupId> </exclusion> <exclusion> <artifactId>spring-beans</artifactId> <groupId>org.springframework</groupId> </exclusion> <exclusion> <artifactId>spring-aop</artifactId> <groupId>org.springframework</groupId> </exclusion> <exclusion> <artifactId>spring-core</artifactId> <groupId>org.springframework</groupId> </exclusion> <exclusion> <artifactId>spring-context</artifactId> <groupId>org.springframework</groupId> </exclusion> </exclusions> </dependency>
- Hibernate core y hibernate-jpa-2.1-api.
- En el tutorial veremos cómo utilizar la caché de segundo nivel de Hibernate, por lo que se incluye el módulo de Hibernate correspondiente (hibernate-jcache) para utilizar como proveedor EHCache 3. El fichero de configuración se encuentra en /src/main/resources/cache.xml
- El driver jdbc para la base de datos a utilizar, en nuestro caso MySQL. En concreto, estoy utilizando MySQL 5.7.31 y un driver de la serie 8 (8.0.21).
- Un pool de conexiones JDBC como c3p0.
- Suelo utilizar log4j, así que incluyo el «bridge» de slf4j correspondiente para redireccionar a log4j los logs de Spring.
- Las dependencias para hacer testing con Spring y JUnit 4 tal y como se describe en el tutorial Testing Spring con JUnit 4.
- Usando una convención para el nombre del método. El método tendrá el prefijo findBy y contendrá el nombre de los atributos por los que se quiera filtrar unidos por los operadores And, Or, Between, LessThan, GreaterThan… (lista completa) Es la forma más simple de hacer una query aunque no nos servirá si tenemos muchos parámetros (el nombre del método será muy largo) o si la consulta es más compleja.
package com.danielme.demo.springdatajpa.repository; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import com.danielme.demo.springdatajpa.model.Country; public interface CountryRepository extends JpaRepository<Country, Long>{ Optional<Country> findByName(String name); List<Country> findByPopulationGreaterThan(Integer population); }
Además de findBy se pueden usar el prefijo countBy para devolver el número de objetos que verifican los filtros definidos:
package com.danielme.demo.springdatajpa.repository; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import com.danielme.demo.springdatajpa.model.Country; public interface CountryRepository extends JpaRepository<Country, Long> { Optional<Country> findByName(String name); List<Country> findByPopulationGreaterThan(Integer population); int countByPopulationGreaterThan(Integer population); }
Nota: en las versiones de Spring Data JPA que requieren Java 8 los métodos que devuelven una única entidad pueden retornar Optional (buena práctica muy recomendable). En todas las versiones de Spring Data JPA también se puede devolver directamente la entidad, o null si no se encuentra, por ejemplo:
Country findByName(String name);
En los nombre de los métodos no sólo se puede hacer referencia a los atributos de la entidad sino también a sus relaciones. Por ejemplo, la siguienta consulta filtra países según el nombre de su confederación.
List<Country> findByConfederationName(String confederationName);
Son especialmente interesantes las posibilidades de filtrado para cadenas (Contains, IgnoreCase, Like, StartsWith, EndsWith…). Contains permite realizar la búsqueda aplicando los comodines ‘%’ a la cadena, algo que no hace Like.
List<Country> findByNameContainingIgnoreCase(String name);
El método anterior genera la siguiente consulta SQL.
select country0_.id as id1_1_, country0_.createBy as createby2_1_, country0_.createdDate as createdd3_1_, country0_.lastModifiedBy as lastmodi4_1_, country0_.lastModifiedDate as lastmodi5_1_, country0_.confederation_id as confeder9_1_, country0_.creation as creation6_1_, country0_.name as name7_1_, country0_.population as populati8_1_ from countries country0_ where upper(country0_.name) like upper(?) escape ? 09-19-2020 01:15:56,001 PM TRACE org.hibernate.type.descriptor.sql.BasicBinder:64 - binding parameter [1] as [VARCHAR] - [%p%] 09-19-2020 01:15:56,002 PM TRACE org.hibernate.type.descriptor.sql.BasicBinder:64 - binding parameter [2] as [CHAR] - [\]
- Definiendo la query en JPQL. Se anota el método con @Query para proporcionar la consulta. Los parámetros de esta consulta serán los parámetros que reciba el método.
@Query("from Country c where lower(c.name) like lower(?1)") List<Country> getByNameWithQuery(String name);
En este ejemplo los parámetros que recibe el método son referenciados en la consulta según el orden en el que aparecen en la signatura del método, pero también se puede utilizar un alias para los mismos gracias a la anotación @Param:
@Query("from Country c where lower(c.name) like lower(:name)") List<Country> findByNameWithQuery(@Param("name") String name);
- Utilizar una NamedQuery ya definida mediante anotaciones en una entidad o bien en el fichero /META-INF/orm.xml. Vamos a añadir una consulta de este tipo a la entidad Country.
@Entity @Table(name = "countries") @NamedQuery(name = "Country.byPopulationNamedQuery", query = "FROM Country WHERE population = ?1") public class Country {
El nombre del método que la ejecuta tiene que coincidir con el de la NamedQuery que a su vez debe tener como prefijo el nombre de la entidad seguido de un punto:
Optional<Country> byPopulationNamedQuery(Integer population);
También podemos obviar esta convención y simplemente indicar el nombre de la consulta
@Query(name = "Country.byPopulationNamedQuery") Optional<Country> cualquierNombre(Integer population);
En cierto modo lo que hemos hecho es llevarnos la query del repositorio a la definición de la entidad.
- El método debe estar anotado con @Modifying si no Spring Data JPA interpretará que se trata de una select y la ejecutará como tal.
- Se devolverá o void o un entero (int\Integer) que contendrá el número de registros afectados.
- El método deberá ser transaccional o bien ser invocado desde otro que sí lo sea.
- Paginación y ordenación
@Query(value = "select * from countries", nativeQuery = true) Page<Country> findAllNative(Pageable pageable);
- Retorno de entidades si los nombres de las columnas devueltas coinciden con sus atributos
- Proyecciones con interfaces
- Obtención de datos en tuplas
- Ejecución de consultas definidas como NamedNativeQuery en una entidad con anotaciones o bien en el fichero /META-INF/orm.xml.
- Actualizaciones con @Modifying
El pom inicial, y que más adelante se modificará según vayamos viendo nuevas funcionalidades, queda tal que así:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.danielme.demo</groupId> <artifactId>spring-data-jpa-demo</artifactId> <version>2.0</version> <name>spring-data-jpa-demo</name> <inceptionYear>2014</inceptionYear> <description>Spring/Spring Data JPA + JPA 2 + Hibernate 5 + c3p0 + EHCache + log4j Integration Demo</description> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <java.version>1.8</java.version> <hibernate.version>5.4.21.Final</hibernate.version> <c3p0.version>0.9.5.5</c3p0.version> <slf4jlog4j.version>1.7.7</slf4jlog4j.version> <mysql.version>8.0.21</mysql.version> <spring.data.jpa.version>2.3.3.RELEASE</spring.data.jpa.version> <spring.version>5.2.8.RELEASE</spring.version> <junit.version>4.13</junit.version> <ehcache.version>3.9.0</ehcache.version> </properties> <developers> <developer> <id>danielme.com</id> <name>Daniel Medina</name> <email>danielme_com@yahoo.com</email> <url>http://danielme.com</url> <roles> <role>developer</role> </roles> </developer> </developers> <scm> <url>https://github.com/danielme-com/Spring-Data-JPA-Demo.git</url> </scm> <licenses> <license> <name>GPL 3</name> <url>http://www.gnu.org/licenses/gpl-3.0.html</url> </license> </licenses> <build> <pluginManagement> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>${java.version}</source> <target>${java.version}</target> <encoding>${project.build.sourceEncoding}</encoding> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-enforcer-plugin</artifactId> <executions> <execution> <id>enforce-java</id> <goals> <goal>enforce</goal> </goals> <configuration> <rules> <requireJavaVersion> <version>${java.version}</version> </requireJavaVersion> </rules> </configuration> </execution> </executions> </plugin> </plugins> </pluginManagement> </build> <dependencies> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-jpa</artifactId> <version>${spring.data.jpa.version}</version> </dependency> <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-core</artifactId> <version>${hibernate.version}</version> </dependency> <dependency> <groupId>org.hibernate.javax.persistence</groupId> <artifactId>hibernate-jpa-2.1-api</artifactId> <version>1.0.2.Final</version> </dependency> <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-jcache</artifactId> <version>${hibernate.version}</version> </dependency> <dependency> <groupId>org.ehcache</groupId> <artifactId>ehcache</artifactId> <version>${ehcache.version}</version> </dependency> <dependency> <groupId>c3p0</groupId> <artifactId>c3p0</artifactId> <version>${c3p0.version}</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-log4j12</artifactId> <version>${slf4jlog4j.version}</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql.version}</version> </dependency> <!-- testing --> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>${junit.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-test</artifactId> <version>${spring.version}</version> <scope>test</scope> </dependency> </dependencies> </project>
Ahora vamos a configurar Spring de las formas posibles: los clásicos XML que ya están en desuso, y en clases Java. Esta configuración es muy similar a la del tutorial Persistencia en BD con Spring: Integrando JPA, c3p0, Hibernate y EHCache por lo que no voy a explayarme al hablar de ella.
Se configura el EntityManager de JPA para Hibernate a partir de la fuente de datos (un pool de conexiones a un MySQL), y su correspondiente gestor de transacciones. Los parámetros de configuración se externalizan en el fichero db.properties. Para Spring Data JPA la única configuración a realizar es indicar la ubicación de los repositorios que en breves momentos vamos a implementar.
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xmlns:tx="http://www.springframework.org/schema/tx" xmlns:p="http://www.springframework.org/schema/p" xmlns:jpa="http://www.springframework.org/schema/data/jpa" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.0.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-4.0.xsd http://www.springframework.org/schema/data/jpa http://www.springframework.org/schema/data/jpa/spring-jpa-1.8.xsd"> <jpa:repositories base-package="com.danielme.demo.springdatajpa.repository" /> <!-- Autowired --> <!-- used to activate annotations in beans already registered in the application context (no matter if they were defined with XML or by package scanning) --> <context:annotation-config /> <!-- scans packages to find and register beans within the application context. --> <context:component-scan base-package="com.danielme.demo.springdatajpa" /> <!-- enable @Transactional Annotation --> <tx:annotation-driven transaction-manager="transactionManager" /> <!-- @PersistenceUnit annotation --> <bean class="org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor" /> <!-- classpath*:*.properties --> <context:property-placeholder location="classpath:db.properties" /> <!-- data source with c3p0 --> <bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close" p:driverClass="${jdbc.driverClassName}" p:jdbcUrl="${jdbc.url}" p:user="${jdbc.username}" p:password="${jdbc.password}" p:acquireIncrement="${c3p0.acquire_increment}" p:minPoolSize="${c3p0.min_size}" p:maxPoolSize="${c3p0.max_size}" p:maxIdleTime="${c3p0.max_idle_time}" p:unreturnedConnectionTimeout="${c3p0.unreturned_connection_timeout}" /> <!-- Hibernate as JPA vendor --> <bean id="jpaAdapter" class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter" /> <bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean" p:dataSource-ref="dataSource" p:packagesToScan="com.danielme.demo.springdatajpa.model" p:jpaVendorAdapter-ref="jpaAdapter"> <property name="jpaProperties"> <props> <prop key="hibernate.hbm2ddl.auto">${hibernate.hbm2ddl.auto}</prop> <prop key="hibernate.dialect">${hibernate.dialect}</prop> <prop key="hibernate.connection.autocommit">${hibernate.connection.autocommit}</prop> <prop key="hibernate.cache.use_second_level_cache">${hibernate.cache}</prop> <prop key="hibernate.cache.use_query_cache">${hibernate.query.cache}</prop> <prop key="hibernate.cache.provider_class">org.ehcache.jsr107.EhcacheCachingProvider</prop> <prop key="hibernate.cache.region.factory_class">org.hibernate.cache.jcache.internal.JCacheRegionFactory</prop> <prop key="hibernate.javax.cache.uri">#{ new org.springframework.core.io.ClassPathResource("/cache.xml").getURL().toString()}</prop> <!--useful for debugging --> <prop key="hibernate.generate_statistics">${hibernate.statistics}</prop> </props> </property> <property name="loadTimeWeaver"> <bean class="org.springframework.instrument.classloading.InstrumentationLoadTimeWeaver" /> </property> </bean> <bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager" p:entityManagerFactory-ref="entityManagerFactory" /> </beans>
La misma configuración realizada programáticamente sería así:
package com.danielme.demo.springdatajpa; import java.beans.PropertyVetoException; import java.util.Properties; import javax.persistence.EntityManagerFactory; import javax.sql.DataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; import org.springframework.core.env.Environment; import org.springframework.core.io.ClassPathResource; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; import org.springframework.transaction.annotation.EnableTransactionManagement; import com.mchange.v2.c3p0.ComboPooledDataSource; @Configuration @PropertySource("classpath:db.properties") @ComponentScan("com.danielme") @EnableTransactionManagement @EnableJpaRepositories(basePackages="com.danielme.demo.springdatajpa.repository") public class ApplicationContext { @Bean(destroyMethod = "close") DataSource dataSource(Environment env) { ComboPooledDataSource ds = new ComboPooledDataSource(); try { ds.setDriverClass(env.getRequiredProperty("jdbc.driverClassName")); } catch (IllegalStateException | PropertyVetoException ex) { throw new RuntimeException( "error while setting the driver class name in the datasource", ex); } ds.setJdbcUrl(env.getRequiredProperty("jdbc.url")); ds.setUser(env.getRequiredProperty("jdbc.username")); ds.setPassword(env.getRequiredProperty("jdbc.password")); ds.setAcquireIncrement(env.getRequiredProperty("c3p0.acquire_increment", Integer.class)); ds.setMinPoolSize(env.getRequiredProperty("c3p0.min_size", Integer.class)); ds.setMaxPoolSize(env.getRequiredProperty("c3p0.max_size", Integer.class)); ds.setMaxIdleTime(env.getRequiredProperty("c3p0.max_idle_time", Integer.class)); ds.setUnreturnedConnectionTimeout( env.getRequiredProperty("c3p0.unreturned_connection_timeout", Integer.class)); return ds; } @Bean public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource, Environment env) { LocalContainerEntityManagerFactoryBean entityManager = new LocalContainerEntityManagerFactoryBean(); // entityManager.setPersistenceXmlLocation("classpath*:META-INF/persistence.xml"); // entityManager.setPersistenceUnitName("hibernatePersistenceUnit"); entityManager.setPackagesToScan("com.danielme.demo.springdatajpa.model"); entityManager.setDataSource(dataSource); entityManager.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); Properties jpaProperties = new Properties(); jpaProperties.put("hibernate.hbm2ddl.auto", env.getRequiredProperty("hibernate.hbm2ddl.auto")); jpaProperties.put("hibernate.dialect", env.getRequiredProperty("hibernate.dialect")); jpaProperties.put("hibernate.connection.autocommit", env.getRequiredProperty("hibernate.connection.autocommit")); jpaProperties.put("hibernate.cache.use_second_level_cache", env.getRequiredProperty("hibernate.cache")); jpaProperties.put("hibernate.cache.use_query_cache", env.getRequiredProperty("hibernate.query.cache")); jpaProperties.put("hibernate.cache.provider_class", org.ehcache.jsr107.EhcacheCachingProvider.class.getCanonicalName()); jpaProperties.put("hibernate.cache.region.factory_class", org.hibernate.cache.jcache.internal.JCacheRegionFactory.class.getCanonicalName()); jpaProperties.put("hibernate.generate_statistics", env.getRequiredProperty("hibernate.statistics")); jpaProperties.put("hibernate.show_sql", env.getRequiredProperty("hibernate.show_sql")); jpaProperties.put("hibernate.format_sql", env.getRequiredProperty("hibernate.format_sql")); try { jpaProperties.put("hibernate.javax.cache.uri", new ClassPathResource("/cache.xml").getURL().toString()); } catch (IOException ex) { throw new IllegalArgumentException("invalid cache.xml location", ex); } entityManager.setJpaProperties(jpaProperties); return entityManager; } @Bean JpaTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { JpaTransactionManager transactionManager = new JpaTransactionManager(); transactionManager.setEntityManagerFactory(entityManagerFactory); return transactionManager; } }
Vamos a trabajar con dos entidades vinculadas por una relación unidireccional.
package com.danielme.demo.springdatajpa.model; import javax.persistence.* @org.hibernate.annotations.Cache(usage = org.hibernate.annotations.CacheConcurrencyStrategy.READ_WRITE) @Entity @Table(name = "countries") public class Country { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, unique = true) private String name; @Column(nullable = false) private Integer population; @Column(updatable = false, nullable = false) @Temporal(TemporalType.TIMESTAMP) private Calendar creation; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "confederation_id") private Confederation confederation; public Country() { super(); } public Country(String name, Integer population, Confederation confederation) { super(); this.name = name; this.population = population; this.confederation = confederation; } @PrePersist public void onPersist() { creation = Calendar.getInstance(); } ...getters y setters }
package com.danielme.demo.springdatajpa.model; import javax.persistence.*; @Entity @Table(name = "confederations") public class Confederation { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, unique = true) private String name; public Confederation(Long id, String name) { this.id = id; this.name = name; } public Confederation(Long id, String name) { this.id = id; this.name = name; } ...getters y setters
El primer repositorio
Ya estamos listos para utilizar Spring Data JPA. Para cada entidad JPA con la que queramos trabajar, en lugar del tradicional DAO, se creará un «repositorio» de Spring Data que no es más que una interfaz que especializa JpaRepository. Esta interfaz proporciona «de serie» las operaciones CRUD más habituales y que por lo tanto no tendremos que implementar. El siguiente diagrama de clases muestra la jerarquía.
La convención a seguir para nombrar el repositorio/interfaz es utilizar el nombre de la entidad con el sufijo «Repository». Asimismo, hay que indicar la entidad para la que se crea el repositorio y el tipo de su clave primaria.
package com.danielme.demo.springdatajpa.repository; import org.springframework.data.jpa.repository.JpaRepository; import com.danielme.demo.springdatajpa.Country; public interface CountryRepository extends JpaRepository<Country, Long> { }
El repositorio ya está listo para ser utilizado como cualquier bean de Spring.
Querys (SELECT) desde Spring Data JPA
Los métodos que el repositorio tiene «out-of-the-box» disminuyen el código de nuestros antiguos dao y permiten estandarizar el uso de repositorios en todos los proyectos en los que usemos Spring Data JPA, pero normalmente no serán suficientes. Una de las características más potentes de Spring Data JPA es la posibilidad de definir en la interfaz del repositorio métodos que ejecuten consultas directamente sin tener que codificar nada. Esto puede hacerse de tres formas distintas:
Tipos devueltos
Ya hemos visto en los ejemplos anteriores que los métodos que realizan consultas pueden devolver o un único objeto o una lista (en este caso se puede utilizar también Collection o Stream de Java 8). Pero en el primer caso si el resultado consta de más de un objeto se lanzará la excepción org.springframework.dao.IncorrectResultSizeDataAccessException.
La clase de los objetos obtenidos debe corresponderse con la respuesta esperada de la consulta JPQL y no tiene que ser necesariamente una entidad JPA. Por ejemplo, añadamos al repositorio un método que comprueba si un país existe dado su nombre exacto utilizando una consulta JPQL que devuelve un valor lógico:
@Query("select case when (count(c) > 0) then true else false end from Country c where c.name = ?1") boolean exists(String name);
Si el resultado es un agregado de varias columnas lo modelaremos con una clase y utilizaremos el mecanismo de proyección estándar de JPA. Por ejemplo, creamos la siguiente clase.
package com.danielme.demo.springdatajpa.model; public class Pair { private Long id; private String value; public Pair(Long id, String value) { super(); this.id = id; this.value = value; } public Long getId() { return id; } public String getValue() { return value; } }
Ahora queremos hacer una consulta para obtener una instancia de Pair correspondiente a los datos de un Country dado por su id de tal modo que el Pair contenga ese id y en value el nombre del país. El mapeo entre los resultados de la consulta y los atributos de la clase Pair lo hacemos mediante el constructor directamente en la propia consulta.
@Query("select new com.danielme.demo.springdatajpa.model.Pair(c.id, c.name) from Country c where c.id = ?1") Pair getPairById(Long id);
También podemos utilizar el mecanismo de tuplas de JPA 2.0.
@Query("select c.id as ID, c.name As value from Country c where c.id = ?1") Tuple getTupleById(Long id);
tuple.get("ID"); tuple.get("value");
Pero si queremos hacerlo al «estilo Spring Data», podemos recurrir a su sistema de proyecciones creando una interfaz con métodos de tipo get para unos atributos «virtuales» cuyos nombres deben coincidir con los de los campos devueltos por la consulta; en caso contrario se lanzará la excepción NotReadablePropertyException. El ejemplo anterior puede ser implementado del siguiente modo.
@Query("select c.id as id, c.name as value from Country c where c.id = ?1") PairProjection getPairByIdInterface(Long id);
package com.danielme.demo.springdatajpa.model; public interface PairProjection { Long getId(); String getValue(); }
Estas proyecciones son más simples y requieren menos código pero las consultas son ligeramente más lentas que las realizadas vía constructor en una clase, por lo que si necesitamos afinar mucho el rendimiento debemos tenerlo en cuenta.
Paginación y ordenación
En numerosas ocasiones será necesario paginar y/o ordenar los datos que obtengamos. Para ello hay que añadir a los métodos de consulta un último parámetro de tipo Pageable o Sort según proceda (los objetos Pageable incluyen un Sort). Por ejemplo, permitamos criterios de ordenación para el método findByNameWithQuery:
@Query("from Country c where lower(c.name) like lower(:name)") List<Country> findByNameWithQuery(@Param("name") String name, Sort sort);
Este método lo usaríamos de la siguiente manera…
countryRepository.findByNameWithQuery("%i%", new Sort( new Sort.Order(Sort.Direction.ASC,"name")));
…pero los constructores de Sort fueron marcados como deprecated e incluso eliminados, y en su lugar se deben utilizar los métodos estáticos by:
countryRepository.findByNameWithQuery("%i%", Sort.by(Sort.Direction.ASC,"name"));
En las consultas definidas mediante el nombre del método los criterios de ordenación también pueden ser definidos, de forma fija, en el nombre del método:
List<Country> findByPopulationGreaterThanOrderByPopulationAsc(Integer population);
En el caso de la paginación procedemos de forma análoga pasando al método un objeto de tipo Pageable. El método puede seguir devolviendo una lista, pero habitualmente será más conveniente retornar un Page ya que este objeto, además de los objetos con los resultados, incluye información detallada sobre la página obtenida (número de página, elementos totales existentes, etc). Otra opción es devolver un Slice (disponible a partir de la versión 1.8), y que a diferencia de Page sólo indica si hay más páginas por obtener.
@Query("from Country c where lower(c.name) like lower(?1)") Page<Country> findByNameWithQuery(String name, Pageable page);
Para invocar este método hay que crear un objeto Pageable con PageRequest indicando el número de página solicitada (la primera es 0) y el tamaño de las mismas. Opcionalmente también se pueden indicar los criterios de ordenación:
Page<Country> page0 = countryRepository.findByNameWithQuery("%i%", new PageRequest(0, 4, Sort.by(Sort.Direction.ASC,"name")));
Los constructores de PageRequest fueron ocultados y actualmente debemos utilizar los métodos estáticos of.
Page<Country> page0 = countryRepository.findByNameWithQuery("%i%", PageRequest.of(0, 4, Sort.by(Sort.Direction.ASC,"name")));
Querys de actualización
La anotación @Query también nos permite definir sentencias JPQL de modificación de datos pero hay que tener en cuenta las siguientes consideraciones (su incumplimiento es fácil de detectar ya que se lanzarán excepciones muy descriptivas):
Aplicando los puntos anteriores, vamos a añadir un método que actualice las fechas de todos los países según una dada.
@Transactional @Modifying @Query("UPDATE Country set creation = (?1)") int updateCreation(Calendar creation);
Las actualizaciones de tipo DELETE se pueden definir directamente mediante el nombre del método, por ejemplo
@Transactional int deleteByName(String name);
El método devuelve el número de entidades eliminadas. De forma alternativa se puede utilizar el prefijo removeBy que tiene el mismo funcionamiento.
@Transactional int removeById(Long id);
Estos borrados ejecutan dos sentencias SQL: un SELECT para encontrar las entidades a eliminar y, si la consulta devuelve al menos una, el DELETE propiamente dicho.
11:54:07,115 AM DEBUG org.hibernate.SQL:92 - select country0_.id as id1_0_, country0_.createBy as createBy2_0_, country0_.createdDate as createdD3_0_, country0_.lastModifiedBy as lastModi4_0_, country0_.lastModifiedDate as lastModi5_0_, country0_.creation as creation6_0_, country0_.name as name7_0_, country0_.population as populati8_0_ from countries country0_ where country0_.id=? 11:54:07,176 AM DEBUG org.hibernate.SQL:92 - delete from countries where id=?
Se puede hacer el DELETE con una única sentencia escribiéndola directamente con JPQL.
@Transactional @Modifying @Query("delete from Country where id=:id") int deleteCountryById(@Param("id") Long id);
Aunque esta segunda opción es más directa y rápida, hay que tener en cuenta que no se ejecutarán los métodos anotados con @PreRemove de las entidades que se eliminen.
Consultas con SQL nativo
Con la anotación @Query también es posible crear consultas en el lenguaje SQL nativo de la base de datos indicándolo con el flag «nativeQuery» que por defecto tiene un valor de falso. Por ejemplo, la siguiente consulta obtiene una lista de todos los países, obsérvese que en el FROM se utiliza el nombre de la tabla y no de la entidad.
@Query(value = "select * from countries", nativeQuery = true) List<Country> findAllNative();
Para las consultas nativas se aplica casi todo lo que hemos visto anteriormente:
No funcionarán las proyecciones con constructor, a menos que definamos la consulta como una NamedNativeQuery que utiliza un resultSetMapping. Por ejemplo:
@SqlResultSetMapping( name = "pairConstructor", classes = @ConstructorResult(targetClass = Pair.class, columns = { @ColumnResult(name = "id", type = Long.class), @ColumnResult(name = "name", type = String.class)})) @NamedNativeQuery( name = "Country.byPopulationNamedNativeQuery", query = "select id, name FROM countries WHERE population = ?1", resultSetMapping = "pairConstructor") public class Country {
@Query(nativeQuery = true) Pair byPopulationNamedNativeQuery(Integer population);
Consultas con JPA Criteria
Veamos cómo funciona el soporte de Spring Data JPA para la ejecución de consultas definidas de forma programática con la API Criteria introducida en JPA 2.0 (Spring Data también se integra con el librería Querydsl, en mi opinión la mejor forma de escribir consultas de forma programática). Para tener esta funcionalidad en un repositorio simplemente hay que heredar de la clase JpaSpecificationExecutor.
public interface CountryRepository extends JpaRepository<Country, Long>, JpaSpecificationExecutor<Country>
Esta interfaz añade al repositorio algunos métodos que permiten ejecutar las consultas encapsuladas en un objeto Specification. Siguiendo lo indicado en el blog oficial de Spring, vamos a crear una clase abstracta con métodos estáticos cada uno de los cuales generará una Specification por ejemplo para buscar un país por su nombre:
package com.danielme.demo.springdatajpa.repository.specifications; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; import org.springframework.data.jpa.domain.Specification; import com.danielme.demo.springdatajpa.Country; public abstract class CountrySpecifications { public static Specification<Country> searchByName(final String name) { return new Specification<Country>() { @Override public Predicate toPredicate(Root<Country> root, CriteriaQuery<?> criteriaQuery, CriteriaBuilder criteriaBuilder) { return criteriaBuilder.equal(root.get("name"), name); } }; } }
Asumiendo que la búsqueda sólo va a devolver un resultado, se realizará de la siguiente forma.
Country country = countryRepository.findOne(CountrySpecifications.searchByName("Mexico"));
Integración con la caché de Hibernate
En el artículo Persistencia en BD con Spring: Integrando JPA, c3p0, Hibernate y EHCache vimos cómo utilizar la caché de Hibernate de segundo nivel para almacenar los resultados de las consultas y así poder mejorar el rendimiento de la capa de persistencia. Esta funcionalidad también puede ser utilizada en los repositorios de Spring Data JPA. Si en el DAO de ejemplo usábamos la siguiente sentencia:
query.setHint(QueryHints.HINT_CACHEABLE, true);
En Spring Data JPA podemos conseguir lo mismo con la anotación @QueryHints que permite aplicar a un método del repositorio los QueryHint que sean necesarios. Por ejemplo,
@QueryHints(value = { @QueryHint (name = "org.hibernate.cacheable", value = "true")}) List<Country> findByPopulationGreaterThan(Integer population);
QueryHints también se puede aplicar a los métodos heredados de JpaRepository simplemente sobrescribiéndolos.
Probando el repositorio
En la siguiente clase se han implementado varios tests para poder ejecutar las consultas definidas en el repositorio. Para más información sobre testing, consultar los tutoriales Testing con JUnit 4 y Spring: testing con JUnit 4.
Para poderlos ejecutar es necesario configurar la conexión a la base de datos MySQL en el fichero db.properties. Los tests se basan en el juego de datos de prueba definidos en el script test.sql el cual se ejecuta antes de cada test gracias a la anotación @Sql de Spring.
He incluido en la raiz del proyecto un fichero Dockerfile y un script para configurar un servidor MySQL ya preparado para ejecutar directamente los tests. La imagen se puede crear y ejecutar con los siguientes comandos desde el directorio raiz del proyecto.
docker build -t spring-data-jpa-mysql . docker run -d -p 3306:3306 spring-data-jpa-mysql
Docker 1: introducción
Docker 2: imágenes y contenedores
package com.danielme.demo.springdatajpa.test; import com.danielme.demo.springdatajpa.ApplicationContext; import com.danielme.demo.springdatajpa.model.Country; import com.danielme.demo.springdatajpa.model.Pair; import com.danielme.demo.springdatajpa.model.PairProjection; import com.danielme.demo.springdatajpa.repository.CountryRepository; import com.danielme.demo.springdatajpa.repository.specifications.CountrySpecifications; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import javax.persistence.Tuple; import java.util.Calendar; import java.util.List; import java.util.Optional; import static org.junit.Assert.*; @RunWith(SpringJUnit4ClassRunner.class) //@ContextConfiguration("file:src/main/resources/applicationContext.xml") @ContextConfiguration(classes = {ApplicationContext.class}) @Sql(scripts = {"/test.sql"}) public class CountryRepositoryTest { private static final Long SPAIN_ID = 2L; @Autowired private CountryRepository countryRepository; @Test public void testExists() { assertTrue(countryRepository.exists("Spain")); } @Test public void testNotExists() { assertFalse(countryRepository.exists("Italy")); } @Test public void testPopulation() { assertEquals(3, countryRepository.countByPopulationGreaterThan(45000000)); assertEquals(3, countryRepository.findByPopulationGreaterThan(45000000).size()); } @Test public void testName() { Optional<Country> optCountry = countryRepository.findByName("Norway"); assertTrue(optCountry.isPresent()); assertEquals("Norway", optCountry.get().getName()); } @Test public void testFindByConfederation() { List<Country> countries = countryRepository.findByConfederationName("CONMEBOL"); assertEquals(1, countries.size()); assertEquals("Colombia", countries.get(0).getName()); } @Test public void testNoName() { assertFalse(countryRepository.findByName("France").isPresent()); } @Test public void testNamedQuery() { assertTrue(countryRepository.byPopulationNamedQuery(115296767).isPresent()); } @Test public void testNamedNativeQuery() { assertEquals(countryRepository.byPopulationNamedNativeQuery(115296767).getValue(), "Mexico"); } @Test public void testQuerysSortingAndPaging() { Page<Country> page0 = countryRepository.findByNameWithQuery("%i%", PageRequest.of(0, 2, Sort.by(Sort.Direction.ASC, "name"))); assertEquals(4, page0.getTotalElements()); assertEquals(2, page0.getTotalPages()); assertEquals("Colombia", page0.getContent().get(0).getName()); } @Test public void testUpdate() { Calendar creation = countryRepository.findByName("Norway").get().getCreation(); assertEquals(5, countryRepository.updateCreation(Calendar.getInstance())); assertTrue(countryRepository.findByName("Norway").get().getCreation().after(creation)); } @Test public void testDeleteByName() { assertEquals(1, countryRepository.deleteByName("Norway")); } @Test public void testRemoveById() { assertEquals(1, countryRepository.removeById(SPAIN_ID)); } @Test public void testRemoveByIdWithQuery() { assertEquals(1, countryRepository.deleteCountryById(SPAIN_ID)); } @Test public void testJpaCriteria() { assertEquals("Mexico", countryRepository .findOne(CountrySpecifications.searchByName("Mexico")).get().getName()); } @Test public void testProjectionConstructor() { Pair pair = countryRepository.getPairById(SPAIN_ID); assertEquals(SPAIN_ID, pair.getId()); assertEquals("Spain", pair.getValue()); } @Test public void testProjectionTuple() { Tuple tuple = countryRepository.getTupleById(SPAIN_ID); assertEquals(SPAIN_ID, tuple.get("ID")); assertEquals("Spain", tuple.get("value")); } @Test public void testProjectionInterface() { PairProjection pair = countryRepository.getPairByIdInterface(SPAIN_ID); assertEquals(SPAIN_ID, pair.getId()); assertEquals("Spain", pair.getValue()); } @Test public void testFindAllListNative() { List<Country> countries = countryRepository.findAllNative(); assertEquals(5, countries.size()); } @Test public void testFindAllPageNative() { Page<Country> page = countryRepository.findAllNative(PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "name"))); assertEquals(5, page.getTotalElements()); assertEquals(2, page.getTotalPages()); } }
Código de ejemplo
El proyecto completo se encuentra disponible en GitHub (para más información sobre cómo utilizar GitHub, consultar este artículo). Este proyecto abarca las tres partes del artículo por lo que hay pequeñas diferencias entre el código expuesto en esta primera parte y el proyecto final.
Otros tutoriales sobre Spring y\o Hibernate
JPA e Hibernate: relaciones y atributos Lazy
Introducción a Spring Boot: Aplicación Web con servicios REST y Spring Data JPA
Persistencia en BD con Spring: Integrando JPA, c3p0, Hibernate y EHCache
SQL nativo con JPA e Hibernate
gracias por tomarte el tiempo para explicar este tipo de cosas, es de bastante ayuda encontrar este tipo de documentación cuando se está iniciando en Spring
me encanta este blog, gracias
@Query(«from Country c where lower(c.name) like lower(?1)»)
Country findByNameWithQuery(@Param(«name») String name);
donde se esta usando el param name ?
que no tendría que ser así
@Query(«from Country c where lower(c.name) like lower(:name)»)
Efectivamente, ya está corregido. Gracias por avisar!!
Muchas gracias por tu tiempo para explicar este tema. Saludos
Excelente integración de conocimientos en un solo articulo. Felicitaciones!