← Persistencia con JPA y PostgreSQL

Sesión 21 · Semana 11

CRUD persistente y consultas del dominio

Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado preparar el ci de la versión persistente. En Servidor continúas la implementación del mismo producto.

Se explica

25 minutos · explicación y demostración

Ya guardas una entidad y sus datos sobreviven al reinicio. Hoy completarás las escrituras y búsquedas sobre PostgreSQL. Una consulta derivada es un método de repositorio cuyo nombre describe el filtro que Spring Data transforma en una consulta.

De un objeto en memoria a una fila con identidad

En la UD2 creábamos un objeto con new, le asignábamos un contador incremental a mano (siguienteId++) y lo metíamos en un ArrayList. Si el objeto cambiaba de campos en cualquier momento, la lista lo reflejaba al instante porque compartían la misma posición de memoria RAM.

Con una base de datos relacional, la vida de un objeto es mucho más sofisticada. Un objeto Java no nace conectado a una tabla: tiene que atravesar una serie de transiciones de estado coordinadas por el Contexto de Persistencia (Persistence Context) de JPA.

El Contexto de Persistencia y el EntityManager

El contexto de persistencia es una zona de memoria gestionada por Hibernate donde residen todas las entidades que la aplicación está manipulando en una transacción activa. El objeto responsable de interactuar con él es el EntityManager.

Cuando utilizas Spring Data JPA no ves al EntityManager de forma directa, pero está ahí detrás de cada llamada a save() o findById().

Los cuatro estados del ciclo de vida de una entidad

Para no cometer errores sutiles con JPA, debes ser capaz de situar cualquier objeto en uno de estos cuatro estados:

El ciclo de vida de una entidad JPA
  1. Transitoria (new)
  2. Gestionada (persist / find)
  3. Separada (close / detach)
  4. Eliminada (remove)
Estado ¿Tiene ID en BD? ¿La conoce Hibernate? ¿Qué ocurre al modificarla?
Transitoria (Transient) No (null) No Cambia en la memoria JVM, la BD no se entera.
Gestionada (Managed) Hibernate detecta cambios automáticamente (dirty checking).
Separada (Detached) No Cambia en la memoria JVM, pero no se sincroniza con la BD.
Eliminada (Removed) Se borrará de la tabla físicamente al confirmar la transacción.

El objeto acaba de ser instanciado con new Tarea(...). Vive en la memoria ordinaria de Java:

Tarea nueva = new Tarea("Configurar HTTPS", "alta");
// nueva.getId() es null. PostgreSQL no sabe que esta tarea existe.

Cuando llamas a repositorio.save(nueva) o cuando recuperas una tarea con repositorio.findById(1L), el objeto pasa al contexto de persistencia:

  • Tiene un identificador único asignado por PostgreSQL.
  • Hibernate lo monitoriza: cualquier cambio en sus atributos durante la transacción será volcado a la base de datos al finalizar sin necesidad de volver a llamar a save().

Ocurre cuando la transacción termina o la conexión se cierra y el objeto viaja hacia el controlador:

  • Sigue teniendo su id (por ejemplo, id = 1L).
  • Pero Hibernate ya no la vigila. Si modificas un campo en una entidad separada, esa modificación no se guarda en la base de datos a menos que la reenganches explícitamente con save() (que invoca merge()).

La entidad estaba gestionada y se ha solicitado su borrado (delete()). Al confirmarse la transacción, Hibernate ejecutará la sentencia SQL DELETE.

Modificar en JPA no es hacer un UPDATE a ciegas

En una aplicación primitiva con JDBC, modificar un registro consistía en concatenar una sentencia SQL de actualización: UPDATE tareas SET titulo = 'Nuevo', prioridad = 'baja' WHERE id = 5;

Si la tarea con id 5 no existía, PostgreSQL respondía que se habían actualizado cero filas, pero la aplicación no se enteraba a menos que comprobaras el contador de retorno.

En JPA y Spring Data, la modificación sigue un patrón mucho más seguro y riguroso:

El flujo de modificación en JPA
  1. Recuperar entidad (404 si falta)
  2. Modificar campos (setters)
  3. Dirty Checking automático
  4. Commit / UPDATE
  1. Recuperamos la entidad existente: llamamos a findById(id). Si no existe, lanzamos de inmediato nuestra RecursoNoEncontradoException (que se traduce en un 404 Not Found). No se actualizan fantasmas.
  2. La entidad pasa a estar gestionada (managed): entra en el contexto de persistencia de Hibernate.
  3. Modificamos sus atributos: aplicamos los nuevos valores mediante sus métodos setters.
  4. Hibernate detecta el cambio (dirty checking): al terminar la transacción (@Transactional), Hibernate compara el objeto con la foto que tomó al recuperarlo de la base de datos. Si detecta campos modificados, emite automáticamente la sentencia UPDATE correspondiente.

¿Hace falta llamar a save() para actualizar?

Dentro de un método anotado con @Transactional, no es estrictamente necesario llamar a repositorio.save(entidad) si la entidad ya estaba gestionada. El mecanismo de dirty checking de Hibernate detecta cualquier llamada a un setter y lanza el UPDATE al confirmar la transacción.

Sin embargo, en Spring Data se recomienda mantener la llamada a save() al final del método por claridad y coherencia arquitectónica: hace que el código sea autodocumentado y señala de forma explícita dónde se sella la operación.

La falacia de «me lo traigo todo y lo filtro en Java»

En las primeras unidades de este curso, cuando un endpoint necesitaba tareas de prioridad alta, la tentación natural era escribir esto:

// ANTIPATRÓN: cargar el mundo en memoria para quedarse con tres elementos
public List<Tarea> buscarUrgentes() {
    return repositorio.findAll().stream()
            .filter(t -> "alta".equals(t.getPrioridad()))
            .toList();
}

Con cincuenta tareas en una lista de pruebas el comportamiento no resulta perceptible. Conviene analizar qué ocurre en un entorno real con 200.000 tareas registradas:

  1. Tráfico de red masivo: la base de datos lee 200.000 filas de disco y las envía completas por el cable TCP hasta tu aplicación Spring Boot (decenas de megabytes innecesarios).
  2. Desperdicio de memoria RAM: Hibernate construye 200.000 instancias completas de Tarea en el heap de la JVM, saturando el recolector de basura (Garbage Collector).
  3. Desprecio a la base de datos: has ignorado los índices de PostgreSQL, su optimizador de costes y su memoria caché relacional, convirtiendo un motor de base de datos de millones de euros en un simple volquete de datos.

La regla de oro del filtrado

El filtrado de datos siempre se realiza en el motor de la base de datos, nunca en la memoria de la aplicación.

La base de datos tiene estructuras en árbol (índices B-Tree) diseñadas para descartar el 99,9 % de los registros en microsegundos. Por el cable de red solo deben viajar las filas que el cliente realmente solicitó.

Cómo funciona la derivación de consultas en Spring Data

Spring Data JPA incluye un analizador léxico (query derivation mechanism) capaz de interpretar el nombre de un método Java y traducirlo automáticamente a sentencias SQL con cláusulas WHERE, ORDER BY y límites.

Basta con declarar la cabecera del método en tu interfaz de repositorio:

public interface TareaRepository extends JpaRepository<Tarea, Long> {
    List<Tarea> findByPrioridad(String prioridad);
}

Al ver ese método, Spring Data descompone el nombre:

  • find / read / get / query: indica que se trata de una consulta de selección (SELECT).
  • By: marca el inicio de los criterios de filtrado (WHERE).
  • Prioridad: busca un atributo llamado prioridad en la entidad Tarea.
  • (String prioridad): asocia el primer parámetro del método al valor del filtro (WHERE prioridad = ?).

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Paso 1 · Retomar el proyecto y preparar la comprobación

  1. Arranca la base de datos y reproduce una creación y consulta persistentes. Abre repository, service y controller de esa entidad.
  2. Prepara varios registros con valores diferentes para los campos que filtrarás. Anota sus ids reales devueltos por el servidor.
  3. Localiza PUT, PATCH y DELETE y marca qué operaciones todavía dependen de listas o suposiciones de la versión en memoria.

Paso 2 · La regla de oro: usa siempre lo que devuelve save()

Mira con atención estas dos líneas. Una de ellas contiene un error conceptual gravísimo:

// INCORRECTO: confiar en el parámetro original
repositorio.save(tarea);
return tarea;

// CORRECTO: utilizar la instancia gestionada que devuelve el método
Tarea guardada = repositorio.save(tarea);
return guardada;

Por qué save() devuelve una instancia

En JPA, el método save() no garantiza que modifique el mismo objeto que le pasaste por parámetro. Lo que hace es sincronizar con el contexto de persistencia y devolver la referencia gestionada.

Esa instancia devuelta tiene garantizado el identificador generado por la secuencia de PostgreSQL, las columnas con valores por defecto y el estado interno actualizado. Si devuelves el parámetro original, puedes estar propagando un objeto sin id o con valores desincronizados.

Paso 3 · Altas en el Service y Controller

En el servicio existente, actualiza los métodos de creación y consulta sin borrar las comprobaciones del proyecto padre. Importa org.springframework.transaction.annotation.Transactional. La anotación delimita una operación con la base de datos; profundizaremos en sus garantías en la sesión 25. Después adapta los métodos HTTP existentes, conservando @Valid, los DTO y Location. El orden para comprobarlos es crear proyecto → crear tarea referenciada → consultar tarea por el id devuelto.

Abre TareaService.java. Observa cómo aplica las reglas de negocio y delega en el repositorio:

package com.ejemplo.gestor.service;

import com.ejemplo.gestor.error.RecursoNoEncontradoException;
import com.ejemplo.gestor.model.Tarea;
import com.ejemplo.gestor.repository.TareaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
public class TareaService {

    private final TareaRepository repositorio;

    public TareaService(TareaRepository repositorio) {
        this.repositorio = repositorio;
    }

    @Transactional
    public Tarea crear(Tarea tarea) {
        // Regla de negocio: una tarea nueva siempre nace sin completar
        tarea.setCompletada(false);

        // Guardamos y devolvemos la entidad gestionada por JPA
        return repositorio.save(tarea);
    }

    @Transactional(readOnly = true)
    public List<Tarea> listar() {
        return repositorio.findAll();
    }

    @Transactional(readOnly = true)
    public Tarea obtener(Long id) {
        return repositorio.findById(id)
                .orElseThrow(() -> new RecursoNoEncontradoException("tarea", id));
    }
}
@Transactional
Delimita la frontera de la transacción en la base de datos. Si el método termina con éxito, Spring confirma (COMMIT) la transacción en PostgreSQL. Si se lanza una excepción no comprobada (RuntimeException), hace un ROLLBACK automático.
@Transactional(readOnly = true)
Optimización para lecturas. Le indica a Hibernate que no necesita vigilar cambios en los objetos (desactiva el dirty checking), lo que ahorra memoria y tiempo de CPU.
orElseThrow con excepción de dominio
Si findById devuelve una caja Optional vacía, lanzamos nuestra RecursoNoEncontradoException. Recuerda: esta excepción pertenece al dominio, no a la capa web; será el manejador global (@RestControllerAdvice) quien la traduzca a un código HTTP 404 Not Found.

Abre TareaController.java. Asegúrate de que las peticiones se traducen mediante el mapper:

@PostMapping
public ResponseEntity<TareaResponse> crear(@Valid @RequestBody TareaRequest peticion) {
    // 1. Traducir de DTO de entrada a entidad del dominio
    Tarea entidad = TareaMapper.aModelo(peticion);

    // 2. Ejecutar el caso de uso en el servicio
    Tarea creada = servicio.crear(entidad);

    // 3. Construir la URI del nuevo recurso para la cabecera Location
    URI ubicacion = ServletUriComponentsBuilder
            .fromCurrentRequest().path("/{id}")
            .buildAndExpand(creada.getId()).toUri();

    // 4. Devolver 201 Created con el DTO de respuesta
    return ResponseEntity.created(ubicacion).body(TareaMapper.aRespuesta(creada));
}

@GetMapping("/{id}")
public TareaResponse detalle(@PathVariable Long id) {
    return TareaMapper.aRespuesta(servicio.obtener(id));
}

Por qué los DTO siguen siendo obligatorios con JPA

Ahora que tenemos @Entity, la tentación de devolver la entidad directamente en el controlador es enorme. No lo hagas jamás.

Si devuelves la entidad directamente: expones nombres de columnas de tu base de datos, corres el riesgo de romper Jackson al serializar relaciones perezosas (Lazy Loading) fuera de la sesión, y cualquier cambio en una tabla romperá el contrato de los clientes de tu API. Los DTO son el contrato público; las entidades son un detalle interno de almacenamiento.

Paso 4 · La comprobación: secuencias y SQL en PostgreSQL

Arranca la aplicación y ejecuta las siguientes comprobaciones en orden:

Envía dos peticiones POST /tareas:

POST http://localhost:8080/tareas
Content-Type: application/json

{
  "titulo": "Auditar índices en PostgreSQL",
  "prioridad": "alta"
}

A continuación:

POST http://localhost:8080/tareas
Content-Type: application/json

{
  "titulo": "Escribir tests de repositorio",
  "prioridad": "media"
}

Observa la consola de Spring Boot. Verás dos sentencias INSERT:

Hibernate:
    insert
    into
        tareas
        (completada, prioridad, titulo)
    values
        (?, ?, ?)

Las respuestas HTTP recibirán id: 1 e id: 2 respectivamente, con cabeceras Location: http://localhost:8080/tareas/1 y Location: http://localhost:8080/tareas/2.

Abre tu cliente de base de datos (DBeaver o psql) y consulta qué ha ocurrido por debajo:

SELECT * FROM tareas;

Consulta ahora la secuencia que PostgreSQL creó automáticamente para la columna id:

SELECT sequencename, last_value FROM pg_sequences
WHERE schemaname = 'public';

Verás una secuencia llamada tareas_id_seq cuyo último valor generado es 2. Las secuencias de PostgreSQL son independientes de las transacciones: garantizan identificadores únicos incluso si decenas de peticiones escriben a la vez.

  1. Haz un GET http://localhost:8080/tareas/1:
    • Código de respuesta: 200 OK.
    • En la consola verás: select t1_0.id, t1_0.completada, t1_0.prioridad, t1_0.titulo from tareas t1_0 where t1_0.id=?.
  2. Haz un GET http://localhost:8080/tareas/999:
    • Código de respuesta: 404 Not Found.
    • Cuerpo JSON estructurado: {"title": "Not Found", "status": 404, "detail": "No existe tarea con id 999"}.

Paso 5 · Altas y consultas para proyectos

Replica de forma autónoma el circuito completo de creación y consulta para la entidad Proyecto:

  1. Crea o actualiza ProyectoRequest con validaciones @NotBlank en el nombre y fechas coherentes.
  2. Crea ProyectoResponse para proyectar los datos hacia la API.
  3. Escribe ProyectoMapper para transformar bidireccionalmente entre DTOs y la entidad @Entity Proyecto.
  4. Implementa en ProyectoService los métodos:
    • crear(Proyecto proyecto) con @Transactional: valida que el nombre no esté duplicado antes de guardar (lanzando 409 Conflict si ya existe).
    • obtener(Long id) con @Transactional(readOnly = true).
    • listar() con @Transactional(readOnly = true).
  5. Implementa en ProyectoController los endpoints:
    • POST /proyectos devolviendo 201 Created con cabecera Location.
    • GET /proyectos devolviendo 200 OK con la lista de DTOs.
    • GET /proyectos/{id} devolviendo 200 OK o 404 Not Found.
  6. Inserta tres proyectos desde tu cliente HTTP y verifica en la consola SQL que la secuencia proyectos_id_seq avanza correctamente.

Modificar y eliminar

Paso 6 · Reemplazo total (PUT) frente a modificación parcial (PATCH)

En la UD3 diseñamos el contrato REST distinguiendo claramente estas dos intenciones:

  • PUT /tareas/{id}: Reemplazo completo. El cliente envía todos los campos editables del recurso. Si omite uno, ese campo se sobrescribe o se anula.
  • PATCH /tareas/{id} o PATCH /tareas/{id}/completar: Modificación parcial o cambio de estado. Solo se alteran los campos especificados en la petición, dejando el resto intactos.

Veamos cómo se traduce esto en nuestro TareaService:

@Transactional
public Tarea reemplazar(Long id, Tarea nuevosDatos) {
    // 1. Asegurar existencia: 404 si no existe
    Tarea existente = obtener(id);

    // 2. Sobrescribir todos los campos editables
    existente.setTitulo(nuevosDatos.getTitulo());
    existente.setPrioridad(nuevosDatos.getPrioridad());
    existente.setCompletada(nuevosDatos.isCompletada());

    // 3. Sellar cambios
    return repositorio.save(existente);
}

@Transactional
public Tarea cambiarEstado(Long id, boolean completada) {
    Tarea existente = obtener(id);
    existente.setCompletada(completada);
    return repositorio.save(existente);
}
Por qué no hacemos nuevosDatos.setId(id); repositorio.save(nuevosDatos);
Ese es el error clásico de quien usa JPA por primera vez. Si creas un objeto nuevo desde el DTO, le plantas el id y llamas a save(), Hibernate ejecutará un SELECT previo, pero sobrescribirá todas las columnas que no vinieran en el DTO con valores null o por defecto, destruyendo información previa como fechas de creación o contadores internos. Cargar primero la entidad existente protege los campos que no deben alterarse.

Paso 7 · La eliminación segura: cómo borrar sin dejar cabos sueltos

Borrar un registro plantea dos cuestiones clave: la comprobación previa de existencia y las consecuencias sobre otras tablas.

En HTTP, un DELETE sobre un identificador que no existe debe responder 404 Not Found (o 204 No Content si se adopta idempotencia ciega, pero en nuestra API hemos establecido informar al cliente cuando pide borrar algo inexistente).

En el servicio lo implementamos así:

@Transactional
public void eliminar(Long id) {
    if (!repositorio.existsById(id)) {
        throw new RecursoNoEncontradoException("tarea", id);
    }
    repositorio.deleteById(id);
}

existsById(id) ejecuta en PostgreSQL una consulta hiperligera:

SELECT count(*) > 0 FROM tareas WHERE id = ?

Si devuelve true, deleteById(id) ejecuta:

DELETE FROM tareas WHERE id = ?
Estrategia Cómo funciona Ventajas Inconvenientes
Borrado físico (Hard Delete) Sentencia SQL DELETE FROM tareas WHERE id = ?. Libera espacio en disco, esquema limpio y sencillo. Irreversible. Se pierde la trazabilidad histórica y de auditoría.
Borrado lógico (Soft Delete) UPDATE tareas SET activo = false, fecha_baja = NOW() WHERE id = ?. Recuperable, mantiene histórico para analítica o auditorías legales. Todas las consultas deben filtrar WHERE activo = true para no mostrar datos borrados.

En este taller utilizaremos borrado físico para comprender a fondo el comportamiento de las claves foráneas en PostgreSQL.

Imagina que un proyecto con id = 1 tiene cinco tareas asociadas. La columna proyecto_id de la tabla tareas apunta a la clave primaria de proyectos.

¿Qué ocurre si intentas ejecutar DELETE FROM proyectos WHERE id = 1;?

PostgreSQL detiene la operación en seco y lanza un error de violación de clave foránea:

ERROR: update or delete on table "proyectos" violates foreign key constraint "fk_tareas_proyecto"
DETAIL: Key (id)=(1) is still referenced from table "tareas".

La base de datos protege tus datos de tu propio código

La base de datos jamás permitirá que queden tareas huérfanas apuntando a un proyecto que ya no existe. Esa es la diferencia entre una base de datos relacional seria y un archivo de texto: la integridad referencial garantizada por el motor.

Si quieres borrar un proyecto, la aplicación debe decidir explícitamente: o borra primero las tareas que contiene, o las reasigna a otro proyecto, o desactiva el proyecto mediante borrado lógico.

Paso 8 · Conectar PUT, PATCH y DELETE

Reemplaza los métodos PUT y DELETE existentes por las versiones persistentes; no publiques dos veces la misma combinación de método y ruta. El endpoint /completar es un ejemplo de cambio de estado: conserva también el PATCH de tu contrato si ya permite otras modificaciones. Dentro del servicio, busca primero la entidad gestionada, modifica sus campos y guarda o termina la transacción según el procedimiento elegido. Comprueba una escritura válida y otra sobre un id ausente.

@PutMapping("/{id}")
public TareaResponse reemplazar(
        @PathVariable Long id,
        @Valid @RequestBody TareaRequest peticion) {
    Tarea datos = TareaMapper.aModelo(peticion);
    Tarea actualizada = servicio.reemplazar(id, datos);
    return TareaMapper.aRespuesta(actualizada);
}

@PatchMapping("/{id}/completar")
public TareaResponse marcarCompletada(@PathVariable Long id) {
    Tarea actualizada = servicio.cambiarEstado(id, true);
    return TareaMapper.aRespuesta(actualizada);
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> eliminar(@PathVariable Long id) {
    servicio.eliminar(id);
    return ResponseEntity.noContent().build();
}

ResponseEntity.noContent().build()

Devuelve un código de estado 204 No Content sin cuerpo en la respuesta. Es el estándar de oro en arquitecturas REST para operaciones DELETE que terminan con éxito.

Paso 9 · El ciclo completo de modificación y borrado

Arranca la aplicación y ejecuta las siguientes pruebas en orden:

Envía una petición para modificar la tarea 1:

PUT http://localhost:8080/tareas/1
Content-Type: application/json

{
  "titulo": "Auditar índices en PostgreSQL (Actualizado)",
  "prioridad": "baja"
}
  • Respuesta: 200 OK con el JSON actualizado y prioridad: "baja".
  • Consola SQL de Hibernate:
Hibernate:
    update
        tareas
    set
        completada=?,
        prioridad=?,
        titulo=?
    where
        id=?

Marca la tarea como completada:

PATCH http://localhost:8080/tareas/1/completar
  • Respuesta: 200 OK con "completada": true.
  • Consola SQL: comprueba que Hibernate ejecuta el UPDATE modificando el valor booleano.

Borra la tarea 1:

DELETE http://localhost:8080/tareas/1
  • Respuesta: 204 No Content (cuerpo vacío).
  • Consola SQL:
Hibernate:
    delete
    from
        tareas
    where
        id=?
  1. Consulta ahora GET http://localhost:8080/tareas/1:
    • Respuesta: 404 Not Found. La tarea ya no existe.
  2. Intenta volver a borrar DELETE http://localhost:8080/tareas/1:
    • Respuesta: 404 Not Found. La aplicación detecta que ya no está y rechaza la operación.
  3. Abre tu cliente SQL (DBeaver o psql) y ejecuta SELECT * FROM tareas WHERE id = 1;: cero filas.

Paso 10 · Modificar y eliminar proyectos

Implementa en Proyecto las operaciones de actualización y borrado:

  1. Añade a ProyectoService:
    • reemplazar(Long id, Proyecto nuevosDatos): carga el existente, actualiza nombre, descripcion y activo, validando que el nombre siga siendo único en el sistema.
    • eliminar(Long id): comprueba existencia con existsById(id) (lanzando 404 si falta) y ejecuta deleteById(id).
  2. Añade los endpoints correspondientes a ProyectoController:
    • PUT /proyectos/{id} devolviendo 200 OK.
    • DELETE /proyectos/{id} devolviendo 204 No Content.
  3. Comprueba el caso de error: intenta modificar o borrar un proyecto con id = 9999 y comprueba que recibes un 404 Not Found en ambos casos.

Consultas derivadas

Paso 11 · El vocabulario de operadores

Spring Data ofrece una gramática muy completa combinando palabras clave en el nombre del método:

Método en el repositorio Sentencia SQL equivalente generada
findByCompletada(boolean completada) WHERE completada = ?
findByPrioridadAndCompletada(String p, boolean c) WHERE prioridad = ? AND completada = ?
findByPrioridadOrCompletada(String p, boolean c) WHERE prioridad = ? OR completada = ?
findByTituloContaining(String fragmento) WHERE titulo LIKE '%' || ? || '%'
findByTituloContainingIgnoreCase(String frag) WHERE LOWER(titulo) LIKE LOWER('%' || ? || '%')
findByCompletadaFalseOrderByPrioridadDesc() WHERE completada = false ORDER BY prioridad DESC
long countByCompletadaFalse() SELECT count(*) ... WHERE completada = false
boolean existsByTitulo(String titulo) SELECT count(*) > 0 ... WHERE titulo = ?
Containing frente a StartingWith y EndingWith
Containing equivale a LIKE '%texto%' (busca en cualquier posición). StartingWith genera LIKE 'texto%' y permite a la base de datos aprovechar un índice B-Tree convencional de texto.
IgnoreCase
Convierte ambos lados a minúsculas con la función SQL LOWER(), garantizando que buscar "servidor" encuentre "Servidor" o "SERVIDOR".

Paso 12 · El error en tiempo de arranque: seguridad de tipos

¿Qué ocurre si te equivocas al escribir el nombre del método en el repositorio? Por ejemplo, si escribes findByTitol(String texto) en lugar de findByTitulo.

A diferencia de JDBC (donde un error de tipeo en un String SQL solo se descubría cuando un usuario ejecutaba la pantalla semanas después), Spring Data valida todos los nombres de métodos al arrancar la aplicación.

Si un método no coincide con ningún atributo de la entidad, Spring Boot detiene el arranque de inmediato con este mensaje:

Caused by: org.springframework.data.mapping.PropertyReferenceException:
No property 'titol' found for type 'Tarea'; Did you mean 'titulo'?

Fíjate en la potencia de la herramienta: no solo rechaza el error antes de que nadie pueda usar la API, sino que inspecciona los campos reales y te sugiere la corrección.

Paso 13 · Añadir consultas derivadas a TareaRepository

Añade las declaraciones de métodos dentro de TareaRepository, conservando extends JpaRepository<Tarea, Long>. Cada palabra después de By debe corresponder a un atributo Java del modelo, no al nombre SQL de la columna. Importa java.util.List cuando el retorno lo necesite. Reinicia para que Spring valide los nombres; después conecta una consulta al servicio y compruébala antes de añadir las demás.

package com.ejemplo.gestor.repository;

import com.ejemplo.gestor.model.Tarea;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface TareaRepository extends JpaRepository<Tarea, Long> {

    // Buscar por prioridad exacta (ej: "alta", "media", "baja")
    List<Tarea> findByPrioridad(String prioridad);

    // Buscar por estado de compleción
    List<Tarea> findByCompletada(boolean completada);

    // Buscar por fragmento en el título ignorando mayúsculas/minúsculas
    List<Tarea> findByTituloContainingIgnoreCase(String fragmento);

    // Contar tareas pendientes
    long countByCompletadaFalse();
}

Paso 14 · Exponer las búsquedas en TareaService y TareaController

Abre TareaService.java y añade los casos de uso correspondientes, todos marcados con readOnly = true:

@Transactional(readOnly = true)
public List<Tarea> buscarPorPrioridad(String prioridad) {
    return repositorio.findByPrioridad(prioridad);
}

@Transactional(readOnly = true)
public List<Tarea> buscarPorEstado(boolean completada) {
    return repositorio.findByCompletada(completada);
}

@Transactional(readOnly = true)
public List<Tarea> buscarPorTexto(String fragmento) {
    return repositorio.findByTituloContainingIgnoreCase(fragmento);
}

@Transactional(readOnly = true)
public long contarPendientes() {
    return repositorio.countByCompletadaFalse();
}

Ahora abre TareaController.java. En la UD3 aprendimos que los filtros sobre listas se reciben como parámetros de consulta (Query Parameters) opcionales sobre el mismo endpoint GET /tareas:

@GetMapping
public List<TareaResponse> listar(
        @RequestParam(required = false) String prioridad,
        @RequestParam(required = false) Boolean completada,
        @RequestParam(required = false) String texto) {

    List<Tarea> resultado;

    if (prioridad != null) {
        resultado = servicio.buscarPorPrioridad(prioridad);
    } else if (completada != null) {
        resultado = servicio.buscarPorEstado(completada);
    } else if (texto != null) {
        resultado = servicio.buscarPorTexto(texto);
    } else {
        resultado = servicio.listar();
    }

    return TareaMapper.aRespuestas(resultado);
}

Paso 15 · La comprobación: inspeccionar el SQL generado

Arranca la aplicación y prueba cada consulta desde tu cliente HTTP o navegador:

Ejecuta GET http://localhost:8080/tareas?prioridad=alta.

Observa la consola de Spring Boot:

Hibernate:
    select
        t1_0.id,
        t1_0.completada,
        t1_0.prioridad,
        t1_0.titulo
    from
        tareas t1_0
    where
        t1_0.prioridad=?

Comprueba que PostgreSQL solo devuelve las tareas con prioridad alta.

Ejecuta GET http://localhost:8080/tareas?texto=postgre.

Mira la consola de Spring Boot:

Hibernate:
    select
        t1_0.id,
        t1_0.completada,
        t1_0.prioridad,
        t1_0.titulo
    from
        tareas t1_0
    where
        lower(t1_0.titulo) like lower(?) escape ''

PostgreSQL aplica la función lower() en ambos lados para hacer la búsqueda insensible a mayúsculas y minúsculas.

Ejecuta GET http://localhost:8080/tareas?completada=false.

Comprueba que solo retorna tareas pendientes y que en la consulta aparece where t1_0.completada=?.

Paso 16 · Los límites de las consultas derivadas

Las consultas derivadas resultan adecuadas para búsquedas directas sobre uno, dos o tres campos, si bien presentan un límite claro de legibilidad.

Mira este nombre de método hipotético:

List<Tarea> findByCompletadaFalseAndPrioridadAndTituloContainingIgnoreCaseOrderByFechaCreacionDesc(
        String prioridad, String texto);

Es larguísimo, difícil de leer de un vistazo y extremadamente frágil si renombras un campo.

Cuándo abandonar las consultas derivadas

Cuando un método requiere más de dos condiciones combinadas, uniones complejas o funciones agregadas, deja de ser un buen caso para derivación por nombre.

En esos escenarios se utiliza la anotación @Query con JPQL (lenguaje de consultas orientado a objetos) o criterios dinámicos con Specifications, que abordaremos en unidades posteriores.

Paso 17 · Consultas derivadas para proyectos

Aplica las consultas derivadas a la entidad Proyecto:

  1. Añade a ProyectoRepository los siguientes métodos de consulta:
    • List<Proyecto> findByActivoTrue(); (obtiene solo proyectos en activo).
    • List<Proyecto> findByNombreContainingIgnoreCase(String fragmento);
    • boolean existsByNombre(String nombre); (para verificar unicidad de nombre sin tener que cargar la entidad completa).
    • long countByActivoTrue();
  2. Modifica la regla de unicidad en ProyectoService: sustituye cualquier búsqueda manual por repositorio.existsByNombre(nombre) antes de crear o actualizar.
  3. Expon en ProyectoController los filtros:
    • GET /proyectos?activo=true
    • GET /proyectos?texto=portal
  4. Comprueba en la consola de Spring Boot que las consultas SQL generadas aplican las cláusulas WHERE activo = true y LOWER(nombre) LIKE LOWER(?).

Paso 18 · Comprobar y registrar el resultado del proyecto

  1. Repite el ciclo de alta, modificación parcial, sustitución y borrado y contrasta los cambios con las filas de PostgreSQL.
  2. Ejecuta cada filtro con coincidencias y sin ellas; comprueba datos y SQL generado. Un listado vacío es una respuesta válida, no un fallo del servidor.

Ampliación si has completado el trabajo

Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.

Reto · La caché de primer nivel y el aislamiento de DTO

Resuelve estas dos preguntas de análisis técnico:

En TareaService, crea un método temporal de prueba anotado con @Transactional:

@Transactional(readOnly = true)
public void experimentoCache(Long id) {
    System.out.println("--- Primera búsqueda ---");
    repositorio.findById(id);

    System.out.println("--- Segunda búsqueda ---");
    repositorio.findById(id);
}
  • Invoca ese método y observa la salida de la consola con las consultas SQL.
  • ¿Cuántas sentencias SELECT ves entre los dos mensajes?
  • Explica qué es la caché de primer nivel de Hibernate, dónde reside en memoria y por qué dentro de una misma transacción no se repiten lecturas para la misma entidad.

Haz un POST /tareas enviando un JSON con un título de más de 300 caracteres (violando la restricción física VARCHAR(120)).

  • La base de datos rechazará la inserción y la transacción terminará en ROLLBACK.
  • Envía ahora una tarea correcta. ¿Qué id recibe? ¿Ha recibido el id anterior o ha saltado al siguiente?
  • Explica por qué las secuencias de PostgreSQL nunca reutilizan números ni retroceden tras un fallo, y por qué las claves primarias numéricas no garantizan ser correlativas sin huecos.
Objetivo mínimoAltas y consultas de Tarea funcionando con JpaRepository, usando la instancia de save() y devolviendo 201/404.
Si lo tienesCircuito completo para Proyecto con DTOs, mappers, regla de unicidad en el servicio y secuencias verificadas.
RetoDemostración de la caché de primer nivel con una sola consulta SQL en logs y explicación de los huecos en secuencias de PostgreSQL.
Ver respuestas

1 · En estado transitorio (*transient*): vive solo en la memoria ordinaria de Java, no tiene clave primaria asignada y JPA no la conoce.

2 · Por el mecanismo de comprobación de suciedad (*dirty checking*): Hibernate compara el estado del objeto con la copia que tomó al entrar en el contexto de persistencia y genera automáticamente el UPDATE antes del commit.

3 · Indica a Hibernate que no mantenga copias de comparación para *dirty checking*, ahorrando consumo de memoria heap y procesamiento de inspección en cada consulta.

4 · La secuencia no retrocede: el número consumido se pierde y la siguiente inserción correcta recibirá el valor siguiente, dejando un hueco en la numeración.

Reto · Bloqueo optimista y borrado en cascada

Analiza estas dos situaciones críticas de producción:

Dos usuarios, Ana y Carlos, cargan en su navegador la tarea 2 al mismo tiempo:

  • Ana cambia el título a "Revisión urgente" y pulsa guardar (10:00:01).
  • Carlos, que tenía la pantalla abierta sin el cambio de Ana, cambia la prioridad a "baja" y pulsa guardar (10:00:02).
  • El guardado de Carlos sobrescribe el título de Ana y lo borra sin que nadie se entere.

Investiga cómo resuelve JPA este problema mediante bloqueo optimista (Optimistic Locking):

  • ¿Qué hace la anotación @Version private Long version; en una @Entity?
  • ¿Qué consulta SQL ejecuta Hibernate en el UPDATE para comprobar si alguien modificó la fila antes?
  • ¿Qué excepción lanza Spring cuando detecta una colisión concurrente y qué código HTTP (409 Conflict) debería devolver la API?

En PostgreSQL puedes definir una clave foránea con la cláusula ON DELETE CASCADE: si se borra un proyecto, el motor borra automáticamente todas sus tareas asociadas en cascada.

  • Explica qué ventaja tiene esto frente a borrar las tareas una a una con un bucle en Java.
  • Explica por qué muchos arquitectos de software prohíben terminantemente ON DELETE CASCADE en tablas con información de negocio crítica. ¿Qué ocurriría si un usuario borra un cliente por error en un CRM?
Objetivo mínimoPUT y DELETE funcionando en Tarea con respuestas 200, 204 y 404 ante id inexistente.
Si lo tienesReemplazo y borrado implementado en Proyecto, con validación de existencia previa y trazabilidad SQL de los UPDATE.
RetoEl mecanismo de @Version (bloqueo optimista) explicado con su SQL correspondiente y el debate técnico de ON DELETE CASCADE documentado.
Ver respuestas

1 · Porque cualquier atributo que no estuviera presente en el DTO recibido se guardará como null o con su valor por defecto, destruyendo información previa de la base de datos.

2 · Ejecuta un SELECT count(*) > 0 FROM ... WHERE id = ?, que comprueba la existencia de la fila sin cargar todas sus columnas en la memoria RAM.

3 · Que la acción se ha completado con éxito en el servidor y no hay ningún contenido o cuerpo que devolver al cliente.

4 · La base de datos aborta la transacción lanzando un error de violación de restricción de clave foránea (FK violation), impidiendo que queden registros huérfanos.

Reto · Índices en PostgreSQL y rendimiento de LIKE

Analiza estas dos cuestiones fundamentales de ingeniería de bases de datos:

Si tu tabla de tareas acumula 500.000 filas y ejecutas constantemente findByPrioridad("alta"), PostgreSQL tiene que realizar un escaneo secuencial de toda la tabla (Sequential Scan), leyendo cada una de las 500.000 filas del disco.

  • Escribe la sentencia SQL nativa para crear un índice sobre la columna prioridad en PostgreSQL:
    CREATE INDEX idx_tareas_prioridad ON tareas(prioridad);
  • Investiga cómo declarar ese mismo índice directamente en el código Java mediante la anotación @Table de tu entidad Tarea:
    @Table(name = "tareas", indexes = {
        @Index(name = "idx_tareas_prioridad", columnList = "prioridad")
    })
  • Explica qué ventaja tiene tener un índice para lecturas y qué coste oculto introduce para las operaciones de INSERT y DELETE.

Cuando ejecutamos findByTituloContainingIgnoreCase("login"), Hibernate genera LIKE '%login%' con un comodín % al principio y al final.

  • Explica por qué un índice tradicional B-Tree de PostgreSQL no se puede utilizar cuando el patrón empieza con un comodín %.
  • Investiga qué extensión oficial de PostgreSQL (pg_trgm / trigramas) y qué tipo de índice especializado (índice GIN o GiST) se utiliza en la industria para acelerar búsquedas de subcadenas en textos reales.
Objetivo mínimoConsultas derivadas de Tarea por prioridad, estado y texto funcionando mediante @RequestParam.
Si lo tienesConsultas de Proyecto implementadas, regla de unicidad optimizada con existsByNombre y SQL verificado.
RetoÍndice declarado con @Table(indexes = ...) y análisis técnico de por qué LIKE '%...' anula los índices B-Tree estándar.
Ver respuestas

1 · Porque fuerza a transferir miles de filas por la red y crear miles de objetos en el heap de la JVM, saturando la memoria y el recolector de basura sin aprovechar los índices del motor relacional.

2 · Traduce el criterio a una cláusula SQL LIKE '%' || ? || '%', buscando cualquier registro que contenga el fragmento especificado en cualquier posición.

3 · En tiempo de arranque de la aplicación, lanzando una PropertyReferenceException antes de que se abra ningún puerto ni se atienda ninguna petición.

4 · Porque el nombre se vuelve ilegible, frágil ante cambios de modelo y difícil de mantener; en esos casos es preferible utilizar consultas @Query o Specifications.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

El CRUD completo opera en PostgreSQL y sus errores siguen el contrato acordado.

Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.