← APIs REST avanzadas

Sesión 29 · Semana 15

Relaciones expuestas y filtros

Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado planificar el incremento y sus dependencias. En Servidor continúas la implementación del mismo producto.

Se explica

25 minutos · explicación y demostración

Retomas el backend persistente del primer trimestre. Hoy harás más útiles sus consultas: un subrecurso expresa una relación en la URL y un filtro reduce los resultados según criterios. Los ejemplos amplían el contrato existente de tu proyecto.

El dilema de la profundidad relacional en REST

En la UD5 aprendiste a modelar relaciones en PostgreSQL y JPA: un proyecto tiene muchas tareas, una tarea pertenece a un proyecto y tiene muchas etiquetas.

Cuando trasladas ese grafo relacional a una API REST surge una pregunta crítica de diseño: ¿cuánta información relacionada debe devolver un endpoint?

Si un cliente solicita GET /proyectos/1, existen dos extremos desastrosos:

❌ El extremo del bucle infinito y el volcado masivo
Si devuelves la entidad directamente o incrustas todo su grafo, el serializador Jackson intentará convertir el Proyecto a JSON; al leer sus tareas, convertirá cada Tarea; al leer el proyecto de la tarea, volverá a serializar el Proyecto, provocando un StackOverflowError por ciclo infinito. Incluso rompiendo el ciclo con anotaciones, una sola petición acabará devolviendo miles de filas de la base de datos (tareas, etiquetas, usuarios).
❌ El extremo de la pobreza de datos (over-fetching / under-fetching)
Si devuelves solo los datos planos del proyecto sin ninguna información de sus tareas, obligas al cliente a realizar decenas de peticiones adicionales para saber siquiera si el proyecto tiene trabajo asignado.

La ley del contrato acotado

Un recurso nunca debe exponer el grafo completo de persistencia.

Cada endpoint debe responder únicamente a las necesidades de su caso de uso de cliente, delimitando la frontera de datos mediante DTOs específicos.

Los tres patrones para exponer recursos relacionados

Para diseñar una API REST limpia y predecible, disponemos de tres patrones fundamentales:

Patrones de diseño para exponer relaciones en APIs REST
  1. 1. Incrustación resumida (Embedded Summary)
  2. 2. Subrecurso dedicado (/padre/{id}/hijos)
  3. 3. Identificador plano o URI de enlace
Patrón En qué consiste Cuándo utilizarlo Ejemplo en nuestra API
1 · Incrustación resumida (Embedded Summary) El DTO padre incluye un resumen compacto del hijo (solo los campos que el cliente necesita para pintar la vista principal). La relación es 1:1 o 1:N pequeña, y los datos del hijo son inseparables del padre en la interfaz. En TareaResponse: incluir proyectoId y proyectoNombre.
2 · Subrecurso dedicado (Sub-resource endpoint) La colección hija no se incrusta en el padre; se expone a través de una ruta jerárquica propia. La colección hija puede ser numerosa, tiene ciclo de vida propio o requiere paginación y filtros independientes. GET /proyectos/{id}/tareas para listar las tareas de un proyecto.
3 · Identificador o Enlace El DTO devuelve únicamente la clave ajena (Long responsableId) o un enlace URI al recurso completo. El cliente rara vez necesita los datos del recurso vinculado de forma inmediata. En TareaResponse: Long responsableId en lugar del usuario completo.

La trampa de la explosión combinatoria de endpoints

Cuando una API empieza a usarse, los clientes necesitan filtrar información:

  • El frontend de proyectos necesita ver las tareas de un proyecto.
  • El panel de incidencias necesita ver las tareas de prioridad alta.
  • El informe de calidad necesita ver las tareas completadas de un proyecto.
  • El buscador necesita encontrar tareas cuyo título contenga una palabra clave.

Un desarrollador principiante suele caer en la explosión combinatoria de rutas:

  • GET /tareas/proyecto/{id}
  • GET /tareas/prioridad/{prioridad}
  • GET /tareas/completadas
  • GET /tareas/proyecto/{id}/prioridad/{prioridad}
  • GET /tareas/proyecto/{id}/completadas

Con tan solo 4 criterios combinables, ¡necesitarías crear 2⁴ = 16 endpoints distintos en tu controlador!

La convención REST para filtros

Un recurso de colección tiene una única ruta canónica en plural (/tareas).

Todas las variaciones de filtrado, búsqueda y ordenación se transmiten mediante parámetros de consulta en la URL (Query Parameters): GET /tareas?prioridad=alta&completada=false.

Estrategias de filtrado en Spring Data JPA

Para resolver consultas con parámetros opcionales en Spring Data existen tres enfoques principales:

Enfoque Cómo funciona Ventajas y Desventajas
Métodos derivados (findBy...) Nombres de método largos como findByProyectoIdAndPrioridadAndCompletada. Inviable con filtros opcionales: si un parámetro viene nulo, Spring busca filas con valor NULL en lugar de omitir el filtro.
JPQL condicional con comprobación de nulos Consulta @Query con cláusulas (:param IS NULL OR columna = :param). Recomendado para 2-5 filtros comunes. Muy legible, nativo de JPA, sin librerías externas y con excelente rendimiento.
Spring Data Specifications (Criteria API) Objetos Specification<T> que componen el predicado SQL dinámicamente con la API de criterios de JPA. Máxima flexibilidad para catálogos con 15+ filtros dinámicos, pero introduce mayor complejidad de código y boiler plate.

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

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

  1. Ejecuta la colección de la versión anterior y abre los listados, sus DTO y los métodos de repositorio que los alimentan.
  2. Prepara registros que coincidan solo con un filtro, con varios y con ninguno. Incluye texto con diferencias de mayúsculas o acentos para observar la política elegida.
  3. Anota las combinaciones de filtros que permitirá tu API y qué respuesta esperas cuando se omitan.

Paso 2 · Subrecursos desacoplados y DTOs específicos

Preparar el mapper usado por los bloques siguientes. Abre TareaMapper, conserva sus métodos anteriores, añade @Component (import org.springframework.stereotype.Component) a la clase y sustituye su constructor privado por uno público sin argumentos. Así Spring puede inyectarlo. Añade estos métodos de instancia e importa TareaResumenResponse, TareaDetalleResponse y Etiqueta de tus paquetes:

public TareaResumenResponse toResumenResponse(Tarea tarea) {
    return new TareaResumenResponse(tarea.getId(), tarea.getTitulo(),
        tarea.getPrioridad(), tarea.isCompletada());
}

public TareaDetalleResponse toDetalleResponse(Tarea tarea) {
    return new TareaDetalleResponse(tarea.getId(), tarea.getTitulo(),
        tarea.getPrioridad(), tarea.isCompletada(), tarea.getProyecto().getId(),
        tarea.getProyecto().getNombre(),
        tarea.getEtiquetas().stream().map(Etiqueta::getNombre).toList());
}

Haz la conversión de detalle dentro de un método transaccional del servicio, porque accede a relaciones LAZY. Los métodos estáticos anteriores pueden conservarse mientras migras sus llamadas; no cambies todos los controladores a la vez.

Cada public record del primer bloque va en un archivo distinto dentro de dto. Antes de cambiar el servicio, conserva o amplía los mappers de la UD3: el nombre toResumen del ejemplo debe corresponder a un método real que construya el DTO con sus componentes. Si tu mapper sigue siendo estático, llámalo por su clase; si decides inyectarlo, conviértelo en componente y ajusta todos sus usos. Modifica el controlador existente y amplía su constructor para recibir los servicios necesarios, sin borrar los otros endpoints.

Creamos dos representaciones distintas según la vista del cliente:

// DTO ligero para listar proyectos sin arrastrar colecciones
public record ProyectoResponse(
    Long id,
    String nombre,
    String descripcion,
    boolean activo,
    LocalDateTime creadoEn
) {}

// DTO resumen de tarea para subrecursos y listados
public record TareaResumenResponse(
    Long id,
    String titulo,
    String prioridad,
    boolean completada
) {}

// DTO detallado de tarea cuando se consulta una tarea individual
public record TareaDetalleResponse(
    Long id,
    String titulo,
    String prioridad,
    boolean completada,
    Long proyectoId,
    String proyectoNombre,
    List<String> etiquetas
) {}

Lo que el DTO deja fuera a propósito

Observa cómo TareaDetalleResponse no contiene un objeto Proyecto anidado con todos sus campos ni entidades Etiqueta, sino solo los datos planos que la pantalla necesita (proyectoId, proyectoNombre y nombres de etiquetas). El contrato es completamente inmune a cambios internos del esquema.

Para resolver el subrecurso sin provocar problemas N+1, definimos la consulta filtrada por la clave foránea en TareaRepository:

public interface TareaRepository extends JpaRepository<Tarea, Long> {

    @Query("SELECT t FROM Tarea t WHERE t.proyecto.id = :proyectoId ORDER BY t.id ASC")
    List<Tarea> findByProyectoId(@Param("proyectoId") Long proyectoId);
}

El servicio valida primero la existencia del proyecto padre antes de buscar sus tareas, garantizando que un identificador erróneo devuelva un código HTTP semántico:

@Service
@Transactional(readOnly = true)
public class TareaService {

    private final TareaRepository tareaRepository;
    private final ProyectoRepository proyectoRepository;
    private final TareaMapper tareaMapper;

    public TareaService(TareaRepository tareaRepository,
                        ProyectoRepository proyectoRepository,
                        TareaMapper tareaMapper) {
        this.tareaRepository = tareaRepository;
        this.proyectoRepository = proyectoRepository;
        this.tareaMapper = tareaMapper;
    }

    public List<TareaResumenResponse> listarTareasDeProyecto(Long proyectoId) {
        if (!proyectoRepository.existsById(proyectoId)) {
            throw new RecursoNoEncontradoException("No existe proyecto con id " + proyectoId);
        }
        return tareaRepository.findByProyectoId(proyectoId).stream()
                .map(tareaMapper::toResumenResponse)
                .toList();
    }
}

En la arquitectura REST, los subrecursos jerárquicos se definen colgando de la ruta del padre:

@RestController
@RequestMapping("/proyectos")
public class ProyectoController {

    private final ProyectoService proyectoService;
    private final TareaService tareaService;

    public ProyectoController(ProyectoService proyectoService, TareaService tareaService) {
        this.proyectoService = proyectoService;
        this.tareaService = tareaService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProyectoResponse> obtenerPorId(@PathVariable Long id) {
        return ResponseEntity.ok(proyectoService.buscarPorId(id));
    }

    // Subrecurso canónico: GET /proyectos/{id}/tareas
    @GetMapping("/{id}/tareas")
    public ResponseEntity<List<TareaResumenResponse>> obtenerTareas(@PathVariable Long id) {
        return ResponseEntity.ok(tareaService.listarTareasDeProyecto(id));
    }
}

Paso 3 · Verificar la frontera de datos en Bruno

Arranca tu aplicación y ejecuta estas dos peticiones en Bruno o Postman:

  1. Consulta del proyecto: GET http://localhost:8080/proyectos/1
    • Código de respuesta: 200 OK.
    • El cuerpo JSON contiene únicamente los atributos del proyecto (id, nombre, descripcion, etc.). Cero tareas incrustadas. Payload inferior a 500 bytes.
  2. Consulta de las tareas del subrecurso: GET http://localhost:8080/proyectos/1/tareas
    • Código de respuesta: 200 OK.
    • El cuerpo JSON devuelve un array con las tareas correspondientes a ese proyecto, con su id, título, prioridad y estado.
  3. Caso límite (proyecto inexistente): GET http://localhost:8080/proyectos/999/tareas
    • Código de respuesta: 404 Not Found.
    • El servicio intercepta la ausencia del padre y emite el error estándar RFC 7807 sin devolver un array vacío engañoso.

Paso 4 · Subrecurso de etiquetas por tarea

Aplica el mismo principio para exponer la relación Many-to-Many entre tareas y etiquetas mediante un subrecurso dedicado:

  1. Define el DTO EtiquetaResponse(Long id, String nombre, String colorHex).
  2. Implementa en TareaController el endpoint de subrecurso: GET /tareas/{id}/etiquetas
  3. En el servicio, valida que la tarea exista (existsById) y recupera sus etiquetas asociadas.
  4. Asegúrate de que EtiquetaResponse no incluya la lista de tareas de vuelta (para no generar ciclos de datos).
  5. Comprueba en Bruno que al consultar las etiquetas de una tarea se devuelve la lista limpia con código 200 OK.

Filtros y búsqueda

Paso 5 · Búsqueda textual insensible a mayúsculas y acentos

Para que un buscador sea usable, escribir "bug", "Bug" o "BUG" debe devolver exactamente los mismos resultados.

En JPQL aplicamos la función LOWER() a ambos lados de la comparación y utilizamos el operador LIKE con comodines:

LOWER(t.titulo) LIKE LOWER(CONCAT('%', :q, '%'))

Si el parámetro :q es nulo o viene vacío, la cláusula (:q IS NULL OR ...) desactiva la condición y devuelve todos los registros.

Paso 6 · Consulta multicriterio de tareas

Añade la consulta al repositorio existente, conservando sus métodos CRUD. Mantén una sola operación de listado en el controlador: reemplaza su implementación para pasar los filtros opcionales al servicio. En la consulta agrupa cada filtro entre paréntesis y enlázalos con AND; un parámetro null desactiva solo ese filtro. Prepara registros que permitan distinguir cada combinación y compara los ids obtenidos. El uso de LOWER resuelve mayúsculas, no elimina acentos por sí mismo.

Añadimos el método de búsqueda en TareaRepository asegurándonos de incluir JOIN FETCH sobre el proyecto para evitar el problema N+1:

public interface TareaRepository extends JpaRepository<Tarea, Long> {

    @Query("""
        SELECT t FROM Tarea t
        JOIN FETCH t.proyecto p
        WHERE (:proyectoId IS NULL OR p.id = :proyectoId)
          AND (:prioridad IS NULL OR t.prioridad = :prioridad)
          AND (:completada IS NULL OR t.completada = :completada)
          AND (:q IS NULL OR LOWER(t.titulo) LIKE LOWER(CONCAT('%', :q, '%')))
        ORDER BY t.id ASC
    """)
    List<Tarea> buscarConFiltros(
        @Param("proyectoId") Long proyectoId,
        @Param("prioridad") String prioridad,
        @Param("completada") Boolean completada,
        @Param("q") String q
    );
}

Por qué un filtro nulo no penaliza la consulta

Al escribir (:proyectoId IS NULL OR p.id = :proyectoId), si el cliente no envía el parámetro en la petición, el valor es null. La primera mitad de la condición se evalúa como TRUE y el motor de base de datos descarta ese filtro sin examinar la columna, evaluando únicamente los filtros que sí fueron proporcionados.

En TareaService, limpiamos los espacios en blanco del término de búsqueda y tratamos cadenas vacías como nulos:

@Service
@Transactional(readOnly = true)
public class TareaService {

    private final TareaRepository tareaRepository;
    private final TareaMapper tareaMapper;

    public TareaService(TareaRepository tareaRepository, TareaMapper tareaMapper) {
        this.tareaRepository = tareaRepository;
        this.tareaMapper = tareaMapper;
    }

    public List<TareaResumenResponse> buscarTareas(Long proyectoId,
                                                  String prioridad,
                                                  Boolean completada,
                                                  String q) {
        // Normalizamos el texto de búsqueda: cadena vacía o espacios se tratan como null
        String terminoLimpio = (q != null && !q.isBlank()) ? q.trim() : null;
        String prioridadLimpia = (prioridad != null && !prioridad.isBlank()) ? prioridad.toUpperCase().trim() : null;

        return tareaRepository.buscarConFiltros(proyectoId, prioridadLimpia, completada, terminoLimpio)
                .stream()
                .map(tareaMapper::toResumenResponse)
                .toList();
    }
}

En TareaController, definimos los parámetros como @RequestParam(required = false):

@RestController
@RequestMapping("/tareas")
public class TareaController {

    private final TareaService tareaService;

    public TareaController(TareaService tareaService) {
        this.tareaService = tareaService;
    }

    @GetMapping
    public ResponseEntity<List<TareaResumenResponse>> listar(
        @RequestParam(required = false) Long proyectoId,
        @RequestParam(required = false) String prioridad,
        @RequestParam(required = false) Boolean completada,
        @RequestParam(required = false) String q
    ) {
        List<TareaResumenResponse> resultado = tareaService.buscarTareas(proyectoId, prioridad, completada, q);
        return ResponseEntity.ok(resultado);
    }
}

Paso 7 · Pruebas combinatorias en Bruno

Abre Bruno o Postman y verifica cómo se comporta el mismo endpoint /tareas según los parámetros que envíes:

  1. Sin parámetros: GET http://localhost:8080/tareas
    • Devuelve la lista completa de todas las tareas del sistema.
  2. Un solo filtro: GET http://localhost:8080/tareas?prioridad=alta
    • Devuelve únicamente tareas con prioridad ALTA.
  3. Filtros combinados: GET http://localhost:8080/tareas?proyectoId=1&completada=false
    • Devuelve solo las tareas pendientes que pertenecen al proyecto 1.
  4. Búsqueda textual: GET http://localhost:8080/tareas?q=auth
    • Devuelve tareas cuyo título contenga la palabra "auth", "Auth" o "AUTORIZACION".
  5. Todos los filtros a la vez: GET http://localhost:8080/tareas?proyectoId=1&prioridad=alta&completada=false&q=login
    • Devuelve la intersección exacta de todos los criterios.
  6. Auditoría de consola SQL:
    • Observa la sentencia emitida en la terminal: verás una única consulta SQL con LEFT/INNER JOIN y la cláusula WHERE evaluada de forma limpia en PostgreSQL.

Paso 8 · Si algo no sale como dice el guion

Síntoma Causa casi segura Qué mirar
Sin filtros no devuelve nada El IS NULL de la condición falta Un parámetro ausente debe desactivar su filtro, no filtrar por vacío
?activo=true devuelve todo El tipo es boolean y no Boolean Un primitivo nunca puede ser null, así que el filtro se aplica siempre con false
La búsqueda distingue mayúsculas Falta normalizar los dos lados LOWER(p.nombre) LIKE LOWER(:q), y el % se añade en Java, no en el JPQL
Parameter with that name did not exist El @Param no coincide El nombre del @Param debe ser idéntico al :nombre de la consulta
Filtrar por texto con acentos no encuentra nada Colación de PostgreSQL Es comportamiento del motor, no de tu código: anótalo como limitación conocida

Paso 9 · Filtro de proyectos por estado y nombre

Aplica el patrón de filtrado a la entidad Proyecto:

  1. Añade en ProyectoRepository una consulta buscarConFiltros que reciba Boolean activo y String q. Fíjate en que es Boolean con mayúscula: necesitas poder distinguir «filtra por activos» de «no filtres por este campo», y un boolean primitivo no puede expresar esa diferencia.
  2. En ProyectoService, normaliza el término de búsqueda: recorta espacios, pásalo a minúsculas y trata la cadena vacía como si no hubieran enviado nada. Un ?q= vacío no debe vaciar la lista.
  3. Actualiza GET /proyectos para admitir ?activo=true&q=portal.
  4. Comprueba las cuatro combinaciones, no solo la que funciona: sin filtros, solo activo, solo q, y los dos a la vez. La tabla de verdad completa es lo que demuestra que la consulta condicional está bien escrita.
  5. Prueba los casos incómodos y decide qué hace cada uno: ?q= vacío, ?q=%, ?q= con 300 caracteres y ?activo=quizas. Ese último debe dar 400, y lo da solo si el tipo es Boolean: compruébalo.
  6. Añade un test de @WebMvcTest que verifique que ?activo=true llega al servicio como Boolean.TRUE y que una petición sin parámetros lo recibe como null. Es la forma de blindar que el filtro opcional sigue siendo opcional dentro de seis meses.
Cómo saber que lo has terminado
Las cuatro combinaciones de filtros devuelven lo que deben; una petición sin parámetros devuelve la lista completa y no una vacía; un valor no booleano devuelve 400; y la búsqueda encuentra igual escribiendo en mayúsculas o en minúsculas.

Paso 10 · Comprobar y registrar el resultado del proyecto

  1. Prueba cada filtro aislado y combinado y contrasta los ids devueltos con los datos preparados.
  2. Consulta un subrecurso desde dos recursos principales distintos: no debe mezclar registros ajenos ni exponer campos internos de las entidades.

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 · ¿Incrustar o enlazar? El parámetro ?expand frente al subrecurso

En APIs públicas de gran escala (como Stripe o GitHub) a veces se utiliza un parámetro de consulta para permitir al cliente decidir si quiere incrustar un recurso relacionado en una sola llamada:

  • Ejemplo: GET /proyectos/1?expand=tareas frente a GET /proyectos/1/tareas.

Analiza y responde con criterio de ingeniería:

  1. ¿Qué ventaja de latencia tiene el parámetro ?expand para una aplicación móvil conectada con cobertura 4G inestable?
  2. ¿Qué coste arquitectónico introduce soportar ?expand en la capa de servicios y en los mappers de DTOs en comparación con mantener dos endpoints separados?
  3. ¿Cómo resolverías la consulta en JPA si el cliente envía ?expand=tareas para evitar que Hibernate ejecute consultas adicionales innecesarias?

Formato de entrega

Incluye esta explicación en el registro de la sesión dentro del repositorio de GitHub, junto al código y las comprobaciones. La entrega es el enlace al repositorio y al commit de la sesión.

Objetivo mínimoDTOs de salida desacoplados y subrecurso GET /proyectos/{id}/tareas devolviendo TareaResumenResponse.
Si lo tienesSubrecurso GET /tareas/{id}/etiquetas implementado con validación de existencia del padre y código 404 semántico.
RetoAnálisis del compromiso técnico de ?expand frente a subrecursos justificado con métricas de red y complejidad en JPA.
Ver respuestas

1 · Porque Jackson serializa el padre, que contiene a los hijos; al serializar cada hijo, Jackson lee la referencia hacia el padre y vuelve a serializarlo recursivamente sin fin hasta agotar la pila de memoria (call stack).

2 · Embedded Summary incrusta un resumen compacto de los datos del hijo dentro del propio JSON del recurso padre; Sub-resource externaliza la colección a una URL jerárquica independiente (/padre/{id}/hijos).

3 · Porque el recurso padre (el proyecto 999) no existe en el sistema. Devolver un array vacío induciría al cliente a pensar erróneamente que el proyecto existe pero que simplemente no tiene tareas asignadas todavía.

4 · Reduce el tamaño del JSON transmitido por red (payload), evita consultas SQL innecesarias de atributos no utilizados y aísla el contrato de la API de cambios en la estructura interna de la entidad proyecto.

Reto · Caracteres especiales y seguridad en búsquedas LIKE

En las consultas con operador LIKE, los caracteres % (cualquier secuencia) y _ (cualquier carácter único) son comodines del motor SQL.

Analiza qué ocurre si un usuario malicioso o despistado introduce en el buscador la cadena ?q=%:

  1. ¿Qué consulta SQL acabaría ejecutando PostgreSQL y qué impacto tendría en el uso de CPU si la tabla tiene 2.000.000 de filas?
  2. ¿Por qué una búsqueda que empieza con comodín a la izquierda (%texto) anula la capacidad de la base de datos de utilizar un índice B-Tree convencional?
  3. Diseña una función de saneamiento en Java que escape los caracteres comodín (\% y \_) antes de pasar el parámetro a la consulta JPQL.
Objetivo mínimoEndpoint GET /tareas con filtros combinables opcionales de proyecto, prioridad y completada.
Si lo tienesBúsqueda textual insensible a mayúsculas con LOWER() y filtro de proyectos implementado.
RetoEscape de comodines SQL (%, _) implementado y análisis de impacto en índices B-Tree justificado.
Ver respuestas

1 · Porque evita la explosión combinatoria de rutas (2^N endpoints), respeta la semántica REST de tener una única URI canónica por recurso y permite al cliente combinar cualquier número de filtros opcionales libremente.

2 · Si el parámetro es null (no se envió en la petición HTTP), la primera parte se evalúa como verdadera y desactiva ese filtro para toda la consulta. Si no es null, se evalúa la segunda parte comparando con el valor de la columna.

3 · Porque muchos motores relacionales (incluido PostgreSQL con ciertas configuraciones de intercalación o JPQL estándar) distinguen mayúsculas de minúsculas en LIKE; convertir ambos operandos a minúsculas garantiza coincidencias uniformes.

4 · El motor de base de datos no puede utilizar un índice B-Tree para saltar directamente a los registros porque no conoce el prefijo de inicio, obligando a un escaneo secuencial completo de la tabla (Full Table Scan).

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

El cliente puede consultar relaciones y filtrar sin conocer las tablas internas.

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