07 - Capa de persistencia
En una arquitectura limpia, los repositorios tienen la responsabilidad de gestionar el acceso a los datos desde la perspectiva del dominio. Esto significa que trabajan exclusivamente con modelos de dominio, abstrayendo por completo los detalles de cómo y dónde se almacenan los datos. Su objetivo principal es proporcionar una interfaz coherente que permita a la capa de dominio interactuar con los datos sin preocuparse por las particularidades de la persistencia.
Para lograr este aislamiento, los repositorios no deberían implementar directamente las operaciones específicas de almacenamiento o recuperación. Aquí es donde entra en juego la introducción de los DAOs, que encapsulan los detalles de persistencia. Por ejemplo, en un sistema que combine una base de datos y un sistema de caché, los repositorios podrían delegar en un DAO el acceso a la caché para recuperar datos rápidamente o almacenar temporalmente resultados, manteniendo así su enfoque en los modelos de dominio.
En este tema, vamos a crear una aplicación que representará la capa de persistencia de nuestra aplicación de gestión de libros.
Dependencia con dominio
Nuestra capa de persistencia tiene que implementar los repositorios que están en la capa de dominio, por lo que tendremos que añadir esa dependencia en nuestro pom:
<dependency> <groupId>es.cesguiro</groupId> <artifactId>daw2-bookstore-domain</artifactId> <version>${es.cesguiro.daw2-bookstore-domain.version}</version> </dependency>
DAO (Data Access Object)
El patrón DAO (Data Access Object) se utiliza para separar y encapsular el acceso a los datos, proporcionando una capa de abstracción que aísla la lógica de acceso a datos del resto de la aplicación. Esto permite que el resto de la aplicación, y en particular la capa de dominio, interactúe con los datos sin preocuparse por los detalles específicos de persistencia. Los DAOs facilitan la delegación de las operaciones de acceso a los datos, de modo que los repositorios se puedan enfocar únicamente en trabajar con los modelos de dominio.
Por ejemplo, supongamos que queremos implementar en nuestra aplicación un sistema de caché con Redis para recuperar libros por su ISBN. En este caso, podemos hacer que el repositorio BookRepositoryImpl utilice dos DAOs: BookDao, que maneja el acceso a la base de datos, y BookRedisDao, que gestiona el almacenamiento en caché. De esta manera, el método findByIsbn intenta primero recuperar el libro de la caché a través de BookRedisDao. Si no se encuentra en la caché, BookDao realiza la consulta en la base de datos, y si el libro es encontrado, se guarda en la caché para futuras solicitudes.
Los datos se guardan en memoria RAM, por lo que la lectura y escritura es mucho más rápida que en una base de datos tradicional.
Redis soporta distintos tipos de estructuras de datos: strings, hashes, listas, conjuntos y sorted sets, lo que lo hace muy versátil.
Aunque es principalmente un almacenamiento en memoria, Redis permite persistir los datos en disco para mantenerlos entre reinicios si es necesario.
Es común usar Redis como nivel de cache entre la aplicación y la base de datos (patrón cache-aside), lo que reduce la carga sobre la base de datos y mejora el rendimiento de lectura.
En el contexto de nuestra aplicación, BookRedisDao actúa como un DAO específico de Redis: guarda y recupera libros usando su ISBN como clave, proporcionando un acceso mucho más rápido que la consulta directa a la base de datos.
Comenzaremos creando una interfaz genérica que servirá como base para nuestros DAOs: GenericDaoDb. Esta interfaz define los métodos básicos para interactuar con una base de datos.
Clases Genéricas
En Java, las clases genéricas nos permiten escribir código flexible y reutilizable. Definir una clase o interfaz genérica significa que podemos trabajar con distintos tipos de datos sin necesidad de escribir código duplicado para cada tipo de entidad. En el caso de los DAOs, esto es útil porque muchas operaciones, como obtener todos los registros o buscar por ID, son comunes para todas las entidades.
Por ejemplo, consideremos el método findAll en la interfaz GenericDao. Este método devuelve una lista de entidades, pero al ser genérico, no podemos saber, a priori, de qué tipo. En lugar de definir un método que solo funcione con, por ejemplo, libros (Page<Book>), usamos un parámetro genérico (Page<T>) para que pueda adaptarse a cualquier tipo de entidad, ya sea Book, Author o Publisher:
public interface GenericDao<T> { Page<T> findAll(int page, int size); Optional<T> findById(Long id); T insert(T entity); T update(T entity); void deleteById(Long id); long count(); }
Al utilizar T, le estamos indicando al compilador que el tipo de la entidad que se va a utilizar será determinado cuando implementemos o instanciemos la interfaz. Esto hace que la interfaz sea flexible y reutilizable para cualquier entidad.
Cuando necesitamos operaciones específicas para ciertas entidades, podemos crear interfaces que extienden GenericDao y agregar los métodos adicionales que correspondan a esas entidades. Por ejemplo, PublisherDao puede extender GenericDao<Publisher> y sólo tendríamos que añadir el método findBySlug, ya que no será común a todos nuestros Daos.
public interface PublisherDao extends GenericDao<PublisherEntity> { Optional<PublisherEntity> findBySlug(String slug); }
De esta forma, podemos crear el resto de DAOs:
public interface BookDao extends GenericDao<BookEntity> { Optional<BookEntity> findByIsbn(String isbn); void deleteByIsbn(String isbn); }
public interface AuthorDao extends GenericDao<AuthorEntity> { Optional<AuthorEntity> findBySlug(String slug); }
Jakarta Persistence
La persistencia de datos en aplicaciones es un aspecto fundamental para el desarrollo de sistemas robustos y escalables. En el entorno de Java, Jakarta Persistence ha surgido como la evolución moderna de Java Persistence API (JPA), ofreciendo una solución estándar para el mapeo objeto-relacional (ORM). Jakarta Persistence permite a los desarrolladores interactuar con bases de datos relacionales utilizando objetos Java de manera eficiente y coherente.
Originalmente, JPA se definía bajo el paquete javax.persistence. Tras la migración de Java EE a la Eclipse Foundation, la especificación se renombró a Jakarta Persistence y los paquetes pasaron a ser jakarta.persistence. La funcionalidad es prácticamente la misma: definir entidades, relaciones y operaciones CRUD de manera orientada a objetos, pero con el namespace actualizado. Esta transición es importante para la compatibilidad con versiones modernas de Spring Boot y Hibernate.
Jakarta Persistence facilita la representación de entidades de persistencia como objetos Java, permitiendo su almacenamiento y recuperación en bases de datos de manera transparente. Al abstraer la lógica de persistencia, simplifica el manejo de la capa de acceso a datos, promoviendo un código más limpio, mantenible y portable entre diferentes proveedores de bases de datos.
JPA define la gestión de datos en aplicaciones mediante la representación de objetos en una base de datos relacional. JPA proporciona un conjunto de interfaces y clases abstractas que permiten a los desarrolladores interactuar con la base de datos de una manera orientada a objetos, sin tener que preocuparse por detalles específicos de la implementación subyacente de la base de datos.
Existen diferentes implementaciones de JPA, entre las que destacan:
- Hibernate: Es una de las implementaciones JPA más populares. Ofrece funcionalidades avanzadas y flexibilidad en el mapeo objeto-relacional, además de herramientas adicionales que simplifican el desarrollo y la gestión de la base de datos.
- EclipseLink: Otra implementación JPA robusta, potente y de alto rendimiento. Ofrece características de mapeo avanzadas, soporte para estándares JPA y herramientas de persistencia útiles.
- Apache OpenJPA: Una implementación JPA que proviene del proyecto OpenJPA de Apache. Proporciona funcionalidades completas de JPA, cumpliendo con los estándares de la API.
Estas implementaciones ofrecen funcionalidades similares en términos de mapeo objeto-relacional y operaciones CRUD, pero pueden diferir en sus características específicas, herramientas adicionales y rendimiento en ciertos contextos.
Al utilizar JPA, los desarrolladores pueden escribir código independiente de la base de datos subyacente, lo que les permite cambiar de proveedor de bases de datos sin tener que modificar considerablemente el código de la aplicación, lo que resulta en sistemas más flexibles y mantenibles a largo plazo.
En Spring Boot tenemos opciones para trabajar con JPA. Si bien Spring Data JPA es una forma común y conveniente de comenzar, podemos personalizar el enfoque dependiendo de las necesidades específicas del proyecto.
Por ejemplo, además de Spring Data JPA, podríamos optar por configurar JPA manualmente agregando las dependencias necesarias de forma individual. Esto nos ofrece un mayor control sobre cada componente que utilizamos.
Además, podemos elegir implementaciones específicas de JPA según nuestras preferencias o requisitos del proyecto. Aunque Hibernate es la implementación JPA predeterminada en Spring Boot, podemos optar por otras implementaciones como EclipseLink o Apache OpenJPA.
Añadiendo la dependencia y configurando la conexión
Como siempre, añadimos jakarta.persitence a nuestro pom:
<dependency> <groupId>jakarta.persistence</groupId> <artifactId>jakarta.persistence-api</artifactId> <version>${jakarta.persistence-api.version}</version> </dependency>
Igual que con las validaciones, tenemos que indicar en algún lugar qué implementación concreta vamos a usar en nuestro proyecto. Por ahora, definiremos esa dependencia para que esté disponible en los tests.
En nuestro caso, vamos a utilizar la configuración común de JPA en un proyecto Spring. Al incluir Spring Data JPA en un proyecto Spring Boot, obtenemos una configuración predeterminada que facilita la interacción con bases de datos utilizando JPA.
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> <version>${org.spring.boot.version}</version> <scope>test</scope> </dependency>
Este starter no solo proporciona Spring Data JPA, sino que también configura automáticamente otros componentes esenciales como Spring JDBC, Spring Transactions, Spring AOP y Spring Aspects, si se requieren para el contexto de persistencia de datos.
Además, añadiremos otra dependencia de Spring en los tests que lleva incorporado JUnit y Mockito, entre otras cosas:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <version>${org.spring.boot.version}</version> <scope>test</scope> </dependency>
En nuestro archivo application.properties de test tendremos que configurar de la conexión a la bbdd. Además, vamos a añadir un par de opciones que nos serán útiles:
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MYSQL spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect spring.datasource.username=root spring.datasource.password=root spring.jpa.show-sql=true
En producción, deberemos usar el driver adecuado a nuestra bbdd
La propiedad spring.jpa.hibernate.ddl-auto es una configuración en aplicaciones Spring que determina cómo Hibernate maneja la creación y actualización de la estructura de la base de datos. Tiene varios valores que permiten definir cómo se realiza esta gestión:
- none: Este valor desactiva cualquier acción automática de creación o actualización de la base de datos por parte de Hibernate. Con este modo, Hibernate no hace ningún cambio en la estructura de la base de datos existente.
- create: Al utilizar este valor, Hibernate creará la estructura de la base de datos si no existe. Si la base de datos ya está presente, Hibernate la eliminará y la volverá a crear, lo que conlleva la pérdida de datos existentes.
- create-drop: Similar a create, Hibernate crea la estructura de la base de datos si no existe. Sin embargo, al cerrar la sesión de Hibernate o detener la aplicación, Hibernate eliminará la base de datos, lo que puede ser útil para entornos de desarrollo o pruebas.
- update: Este valor indica a Hibernate que actualice la estructura de la base de datos según los cambios en las entidades. No elimina los datos existentes, pero puede modificar o eliminar columnas, tablas, etc., para reflejar los cambios en el modelo de datos.
La última línea (spring.jpa.show-sql=true) es una configuración que se utiliza en aplicaciones Spring con Hibernate como implementación JPA. Cuando se establece a true, esta propiedad le indica a Hibernate que imprima las consultas SQL generadas por la aplicación en la consola, lo que nos puede ser útil mientras estamos en desarrollo.
Entidades
JPA utiliza sus propias entidades que mapean una tabla de la bbdd. Para ello, debemos añadir la anotación @Entity a nuestras entidades.
@Entity public class PublisherJpaEntity implements Serializable {
Una entidad de JPA debe tener un constructor vacío. Si no defines ningún otro constructor, el compilador de Java generará automáticamente uno vacío, por lo que JPA podrá instanciar la entidad sin problema.
Sin embargo, si añades un constructor con parámetros y no defines explícitamente un constructor vacío, JPA dará error al intentar instanciar la entidad.
JPA intentará conectar la entidad con la tabla correspondiente. El problema es que nuestras entidades llevan el sufijo JpaEntity, con lo que no encontrará ninguna tabla llamada PublisherJpaEntity. Por suerte, la solución es muy sencilla: indicarle el nombre de la tabla mediante la anotación @Table:
@Entity @Table(name = "publishers") public class PublisherJpaEntity implements Serializable {
Una entidad debe tener definida una clave primaria. La forma más sencilla de definir la clave primaria es mediante la anotación @Id.
Ademas, la anotación @GeneratedValue en JPA, junto con @Id, se utiliza para especificar cómo se generan los valores para una clave primaria en una entidad persistente. En particular, @GeneratedValue se usa para indicar la estrategia que se utilizará para generar los valores de las claves primarias de manera automática por parte de la base de datos.
La estrategia GenerationType.AUTO especifica que JPA deja que el persistence provider (por ejemplo, Hibernate) elija automáticamente la mejor estrategia según la base de datos que se esté usando. :
@Entity @Table(name = "publishers") @RedisHash("Publisher") public class PublisherJpaEntity implements Serializable { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String name; private String slug; //constructores, getters y setters
¿Qué pasa si los nombres de los campos de la tabla no coinciden con los de los atributos? Por ejemplo, en BookJpaEntity tenemos los campos titleEs, titleEn… mientras que en nuestra tabla los nombres son title_es, title_en… JPA no es capaz de asociar el atributo a un campo de la tabla. Por suerte, contamos con la anotación @Column para indicarle el nombre de la columna en la bbdd:
@Entity @Table(name = "books") public class BookJpaEntity implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String isbn; @Column(name = "title_es") private String titleEs; ...
Relaciones entre entidades
En JPA podemos representar relaciones entre entidades para facilitar el tratamiento de datos. Para hacerlo, contamos con una serie de anotaciones según el tipo de relación que queramos representar:
- @OneToOne: Define una relación uno a uno entre dos entidades.
- @OneToMany: Define una relación de uno a muchos, donde una entidad tiene una asociación con múltiples entidades de otro tipo.
- @ManyToOne: Establece una relación muchos a uno, indicando que varias entidades de una clase están relacionadas con una única entidad de otra clase.
- @ManyToMany: Define una relación muchos a muchos entre dos entidades, lo que implica que una entidad puede estar asociada con múltiples entidades de otro tipo y viceversa.
Las relaciones pueden ser bidireccionales o unidireccionales. Una relación bidireccional tiene dos lados:
- un lado propietario (owning side), y
- un lado inverso o no propietario (inverse / non-owning side).
En cambio, una relación unidireccional tiene solo el lado propietario.
El lado propietario de una relación es el que controla las actualizaciones que se reflejan en la base de datos.
Por ejemplo, BookJpaEntity tiene dos relaciones con PublisherJpaEntity y AuthorJpaEntity:
@Entity @Table(name = "books") public class BookJpaEntity implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; ... @ManyToOne(fetch = FetchType.LAZY) private PublisherJpaEntity publisher; @ManyToMany @JoinTable( name = "books_authors", joinColumns = @JoinColumn(name = "book_id"), inverseJoinColumns = @JoinColumn(name = "author_id") ) private List<AuthorJpaEntity> authors; ...
El primer tipo de relación (con PublisherJpaEntity) será ManyToOne. El atributo fetch indica cómo queremos recuperar los datos asociados. En este caso, indicamos que queremos Lazy Loading (carga perezosa).
El segundo tipo sería @ManyToMany. En este tipo de relaciones tenemos que indicarle la tabla de enganche y las claves ajenas de dicha tabla. Para eso, utilizamos la anotación @JoinTable.
Aunque podríamos dejarlo así, vamos a romper la relación ManyToMany y convertirla en una relación OneToMany/ManyToOne con la entidad intermedia BookAuthorJpaEntity. Este enfoque presenta varias ventajas frente al uso de una relación ManyToMany directa:
- Mayor flexibilidad: permite añadir información adicional sobre la relación, como el rol del autor, el orden en que aparece, o cualquier otro metadato relevante.
- Control total sobre la persistencia: al ser una entidad propia, podemos gestionar su ciclo de vida, aplicar cascade operations, y eliminar registros huérfanos de forma más controlada.
- Mejor legibilidad y mantenibilidad: el modelo refleja de manera más fiel la estructura real de la base de datos, donde la relación muchos a muchos siempre se materializa mediante una tabla intermedia.
- Facilidad para realizar consultas específicas: se pueden definir consultas directamente sobre la entidad intermedia, lo que permite filtrar, ordenar o agrupar resultados de forma más precisa.
Convertir la relación ManyToMany en una estructura OneToMany/ManyToOne aporta una mayor robustez y flexibilidad al modelo de datos, lo que resulta especialmente útil en proyectos que pueden evolucionar o requerir información adicional en las asociaciones:
@Entity @Table(name = "books") public class BookJpaEntity implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String isbn; @Column(name = "title_es") private String titleEs; @Column(name = "title_en") private String titleEn; @Column(name = "synopsis_es", length = 2000) private String synopsisEs; @Column(name = "synopsis_en", length = 2000) private String synopsisEn; @Column(name = "base_price") private BigDecimal basePrice; @Column(name = "discount_percentage") private Double discountPercentage; private String cover; @Column(name = "publication_date") private String publicationDate; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "publisher_id") private PublisherJpaEntity publisher; @OneToMany(mappedBy = "book", cascade = CascadeType.ALL, orphanRemoval = true) private List<BookAuthorJpaEntity> bookAuthors = new ArrayList<>();
@Entity @Table(name = "book_author") public class BookAuthorJpaEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "book_id") private BookJpaEntity book; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "author_id") private AuthorJpaEntity author;
Además, podemos crear dos métodos en BookJpaEntity para leer/escribir los autores:
public List<AuthorJpaEntity> getAuthors() { return bookAuthors.stream().map(BookAuthorJpaEntity::getAuthor).collect(Collectors.toList()); } public void setAuthors(List<AuthorJpaEntity> authors) { this.bookAuthors.clear(); for (AuthorJpaEntity author : authors) { BookAuthorJpaEntity bookAuthor = new BookAuthorJpaEntity(this, author); this.bookAuthors.add(bookAuthor); } }
Mapeadores
Como es obvio, necesitamos poder convertir los DTOs que nos llegan de dominio (BookEntity) a entidades de JPA (BookJpaEntity). Podríamos hacer los mapeadores a mano, como hicimos en dominio, pero vamos a utilizar una librería que nos ayudará en la tarea: MapStruct.
MapStruct es una librería de mapeo de Java que te permite convertir automáticamente entre distintos tipos de objetos (DTOs, entidades, JPA entities, etc.) sin escribir manualmente los métodos de copia de propiedades.
Ventajas principales:
- Generación automática de código en tiempo de compilación → no hay reflexión en tiempo de ejecución.
- Alto rendimiento comparado con librerías de mapeo dinámico como ModelMapper.
- Puedes personalizar las conversiones con anotaciones como @Mapping, @Mappings y métodos auxiliares.
- Compatible con Spring (componentModel = “spring”) para inyección automática de beans mappers.
Como siempre, añadimos la dependencia correspondiente:
<dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct</artifactId> <version>${org.mapstruct.version}</version> </dependency>
Además, en el plugin de compilación de Maven debemos indicar el procesador de anotaciones:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>${org.apache.maven.compiler.plugin.version}</version> <configuration> <source>${maven.compiler.source}</source> <target>${maven.compiler.target}</target> <parameters>true</parameters> <annotationProcessorPaths> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>${org.mapstruct.version}</version> </path> </annotationProcessorPaths> </configuration> </plugin>
Con ésto, ya podemos crear mapeadores con MapStruct, usando la anotación @Mapper:
@Mapper public interface PublisherMapper { PublisherMapper INSTANCE = Mappers.getMapper(PublisherMapper.class); PublisherJpaEntity publisherEntityToPublisherJpaEntity(PublisherEntity publisherEntity); PublisherEntity publisherJpaEntityToPublisherEntity(PublisherJpaEntity publisherJpaEntity); }
Si los campos de las dos clases involucradas (en este caso PublisherEntity y PublisherJpaEntity) son del mismo tipo y tienen el mismo nombre, no hace falta añadir nada más. MapStruct creará la implementación de forma automática.
DAOs JPA
En general, un DAO JPA tendrá:
- Una referencia a EntityManager, que es el componente central de JPA para gestionar la persistencia.
- Implementaciones de los métodos definidos en GenericDao, como findAll, findById, insert, update o deleteById.
- Métodos específicos para la entidad concreta, como findByIsbn en BookDao.
EntityManager actúa como el puente entre nuestras entidades Java y la base de datos. Permite realizar operaciones CRUD (crear, leer, actualizar, borrar) de manera transparente, sin tener que escribir SQL manualmente, y también gestionar el ciclo de vida de las entidades dentro del contexto de persistencia.
Con EntityManager podemos:
Operación | Método | Descripción |
---|---|---|
Crear | entityManager.persist(entity) | Inserta un nuevo objeto en la base de datos (cuando se sincronice el contexto). |
Leer | entityManager.find(BookJpaEntity.class, id) | Recupera la entidad con la clave primaria indicada. |
Actualizar | entityManager.merge(entity) | Sincroniza los cambios de un objeto con la base de datos. |
Borrar | entityManager.remove(entity) | Elimina la entidad de la base de datos. |
Consultar | entityManager.createQuery(“SELECT b FROM BookJpaEntity b”, BookJpaEntity.class) | Ejecuta una consulta JPQL sobre entidades |
Sincronizar | entityManager.flush() | Fuerza la escritura inmediata de los cambios pendientes en la base de datos. |
Contexto de persistencia
EntityManager mantiene un persistence context, es decir, un conjunto de entidades gestionadas en memoria.
Si obtenemos la misma entidad varias veces, JPA nos devolverá la misma instancia en memoria, y cualquier cambio realizado se sincronizará automáticamente con la base de datos cuando se complete la transacción.
En Jakarta Persistence (JPA), la anotación @PersistenceContext es la forma estándar de inyectar un EntityManager gestionado por el contenedor.
Esto significa que el contenedor se encarga del ciclo de vida del EntityManager: abrirlo, cerrarlo y sincronizarlo con las transacciones activas.
public class PublisherDaoJpa implements PublisherDao { @PersistenceContext private EntityManager entityManager;
De esta manera, cada DAO tiene un control completo sobre cómo se interactúa con la base de datos, permitiendo personalizar consultas, optimizar el rendimiento y combinar operaciones con otros sistemas como Redis, siguiendo el patrón de repositorios que hemos definido en la capa de dominio.
Acceso a datos
Con JPA disponemos de tres formas principales de consultar y operar con la base de datos:
JPQL / HQL (JPA Query Language)
JPQL (Java Persistence Query Language) es un lenguaje de consultas orientado a entidades, no a tablas. Permite escribir consultas similares a SQL, pero utilizando los nombres de las clases y sus atributos, en lugar de tablas y columnas.
List<BookJpaEntity> books = entityManager .createQuery("SELECT b FROM BookJpaEntity b WHERE b.author = :author", BookJpaEntity.class) .setParameter("author", "Isaac Asimov") .getResultList();
Ventajas | Inconvenientes |
---|---|
Más legible y cercano al modelo de dominio. | No soporta funciones específicas de cada motor SQL. |
Independiente del motor de base de datos. | |
Permite aprovechar relaciones (joins, fetch) entre entidades. |
Native SQL
Permite ejecutar consultas SQL crudas directamente sobre la base de datos, usando createNativeQuery. Se utiliza cuando JPQL no es suficiente o se necesita aprovechar funciones específicas del motor SQL.
List<Object[]> result = entityManager .createNativeQuery("SELECT id, title FROM books WHERE author = ?") .setParameter(1, "Isaac Asimov") .getResultList();
También se pueden mapear los resultados a entidades o DTOs:
List<BookJpaEntity> books = entityManager .createNativeQuery("SELECT * FROM books", BookJpaEntity.class) .getResultList();
Ventajas | Inconvenientes |
---|---|
Control total sobre la consulta. | Dependiente del dialecto de la base de datos. |
Permite usar funciones y optimizaciones específicas del motor SQL. | No se aprovecha la abstracción del modelo de entidades. |
Más propenso a errores de mantenimiento. |
Criteria API (CriteriaBuilder / CriteriaQuery)
Es un query builder tipado y programático que permite construir consultas dinámicamente mediante código Java, sin concatenar strings. Ideal para filtros opcionales o búsquedas avanzadas.
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<BookJpaEntity> cq = cb.createQuery(BookJpaEntity.class); Root<BookJpaEntity> book = cq.from(BookJpaEntity.class); cq.select(book) .where(cb.equal(book.get("author"), "Isaac Asimov")); List<BookJpaEntity> results = entityManager.createQuery(cq).getResultList();
Ventajas | Inconvenientes |
---|---|
Totalmente tipado y seguro en tiempo de compilación. | Verboso y menos legible que JPQL. |
Muy útil para construir consultas dinámicas. | Más complejo para consultas simples. |
Evita errores de concatenación o typos en nombres de campos. |
Por ejemplo, en nuestro BookJpaDaoImpl, podemos añadir los métodos necesarios para recuperar datos:
public class BookJpaDaoJpaImpl implements BookJpaDao { @PersistenceContext private EntityManager entityManager; @Override public long count() { return entityManager.createQuery("SELECT COUNT(b) FROM BookJpaEntity b", Long.class) .getSingleResult(); } @Override public List<BookJpaEntity> findAll(int page, int size) { int pageIndex = Math.max(page - 1, 0); String sql = "SELECT b FROM BookJpaEntity b ORDER BY b.id"; TypedQuery<BookJpaEntity> bookJpaEntityPage = entityManager .createQuery(sql, BookJpaEntity.class) .setFirstResult(pageIndex * size) .setMaxResults(size); return bookJpaEntityPage.getResultList(); } @Override public Optional<BookJpaEntity> findById(Long id) { return Optional.ofNullable(entityManager.find(BookJpaEntity.class, id)); } @Override public Optional<BookJpaEntity> findByIsbn(String isbn) { String sql = "SELECT b FROM BookJpaEntity b WHERE b.isbn = :isbn"; try { return Optional.of(entityManager.createQuery(sql, BookJpaEntity.class) .setParameter("isbn", isbn) .getSingleResult()); } catch (Exception e) { return Optional.empty(); } }
Y nuestro repositorio:
public class BookRepositoryImpl implements BookRepository { private final BookJpaDao bookJpaDao; public BookRepositoryImpl(BookJpaDao bookJpaDao) { this.bookJpaDao = bookJpaDao; } @Override public Page<BookEntity> findAll(int page, int size) { List<BookEntity> content = bookJpaDao.findAll(page, size).stream() .map(BookMapper.INSTANCE::fromBookJpaEntityToBookEntity) .toList(); long totalElements = bookJpaDao.count(); return new Page<>(content, page, size, totalElements); } @Override public Optional<BookEntity> findByIsbn(String isbn) { return bookJpaDao.findByIsbn(isbn) .map(BookMapper.INSTANCE::fromBookJpaEntityToBookEntity); } @Override public Optional<BookEntity> findById(Long id) { return bookJpaDao.findById(id) .map(BookMapper.INSTANCE::fromBookJpaEntityToBookEntity); }
Actualizaciones, inserciones y borrados
Para las inserciones y actualizaciones, podemos crear el método sav() en el repositorio, y saber si es una actualización o inserción según si tiene o no id:
@Override public BookEntity save(BookEntity bookEntity) { BookJpaEntity bookJpaEntity = BookMapper.INSTANCE.fromBookEntityToBookJpaEntity(bookEntity); if(bookEntity.id() == null) { return BookMapper.INSTANCE.fromBookJpaEntityToBookEntity(bookJpaDao.insert(bookJpaEntity)); } return BookMapper.INSTANCE.fromBookJpaEntityToBookEntity(bookJpaDao.update(bookJpaEntity)); }
En cuanto al borrado, lo hacemos por isbn:
@Override public void deleteByIsbn(String isbn) { bookJpaDao.deleteByIsbn(isbn); }
En nuestro DAO, los métodos insert y deleteByIsbn son sencillos:
@Override public BookJpaEntity insert(BookJpaEntity bookJpaEntity) { entityManager.persist(bookJpaEntity); return bookJpaEntity; } @Override public void deleteById(Long id) { entityManager.remove(entityManager.find(BookJpaEntity.class, id)); }
En cuanto al update, al hacer mapeos entre BookJpaEntity y BookEntity y haber roto la relación ManyToMany con Authors, primero debemos asegurarnos que eliminamos los autores anteriores:
@Override public BookJpaEntity update(BookJpaEntity bookJpaEntity) { BookJpaEntity managed = entityManager.find(BookJpaEntity.class, bookJpaEntity.getId()); if(managed == null) { throw new ResourceNotFoundException("Book with id " + bookJpaEntity.getId() + " not found"); } managed.getBookAuthors().clear(); entityManager.flush(); return entityManager.merge(bookJpaEntity); }