# Día 9: Acceso a Datos Artesanal con JDBC: El Dolor del SQL Manual

> Connection, PreparedStatement, Mapeo Manual y Transacciones en Java 17/21

- Plan: [JavaMastery](https://aprende-java.webs-economicas.es/), día 9 de 30
- Fase 2: De Vanilla a la Necesidad de Herramientas
- Dedicación: 4h
- Profundidad: para dominar
- Revisado: 2026-10-29
- Versión interactiva: https://aprende-java.webs-economicas.es/dias/jdbc-sql-manual/

## Al terminar este día sabrás

- Escribir consultas JDBC seguras frente a inyección SQL con `PreparedStatement`.
- Mapear un `ResultSet` a objetos sin perder nulos ni recursos abiertos.
- Controlar una transacción a mano y saber qué pasa si algo falla a mitad.
- Entender por qué existe el pool de conexiones y cómo se dimensiona.


## Conceptos

### 1. PreparedStatement: Inmunidad frente a Inyección SQL

Precompila la consulta en la base de datos y envía los parámetros en un canal tipado separado. Jamás se deben concatenar cadenas de texto en SQL.

**Cómo funciona por dentro.**

Concatenar la entrada del usuario en una cadena SQL hace que esa entrada forme parte del código que la base de datos interpreta. Con `"... WHERE titulo = '" + texto + "'"`, un texto como `' OR '1'='1` cambia el significado de la consulta. Eso es inyección SQL.

`PreparedStatement` envía la sentencia con marcadores `?` y los valores por separado. El motor analiza la estructura una vez y trata los parámetros siempre como datos, nunca como SQL, así que no hay forma de alterar la consulta. Además, el plan de ejecución se puede reutilizar entre llamadas.

Los marcadores solo sirven para valores. No puedes parametrizar nombres de tabla, de columna ni la dirección de un `ORDER BY`. Si esos fragmentos dependen del usuario, valídalos contra una lista blanca cerrada antes de construir la cadena.

*Consulta parametrizada y orden con lista blanca*

```java
private static final Map<String, String> COLUMNAS_ORDEN = Map.of(
    "titulo", "titulo",
    "fecha", "creada_en"
);

public List<Tarea> buscar(String texto, String ordenarPor) throws SQLException {
    String columna = COLUMNAS_ORDEN.getOrDefault(ordenarPor, "creada_en"); // nunca la entrada directa
    String sql = "SELECT id, titulo, completada FROM tarea WHERE titulo ILIKE ? ORDER BY " + columna;

    try (Connection con = dataSource.getConnection();
         PreparedStatement ps = con.prepareStatement(sql)) {
        ps.setString(1, "%" + texto + "%");         // el valor viaja como dato
        try (ResultSet rs = ps.executeQuery()) {
            var resultado = new ArrayList<Tarea>();
            while (rs.next()) {
                resultado.add(mapear(rs));
            }
            return resultado;
        }
    }
}
```

- **En una línea:** `ps.setString(1, email); garantizando inmunidad a SQL Injection.`
- **Por qué importa:** Evita la vulnerabilidad de seguridad más destructiva de la historia del software web.
- **Error típico:** Hacer concatenación directa en la consulta SQL en vez de usar parámetros ? con PreparedStatement.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** Seguridad, Rendimiento SQL, JDBC

### 2. El Dolor del Mapeo Manual con ResultSet

En JDBC puro, cada columna debe extraerse manualmente con `rs.getLong("id")`, `rs.getString("nombre")` fila por fila.

**Cómo funciona por dentro.**

Un `ResultSet` es un cursor que empieza antes de la primera fila. Cada llamada a `next()` avanza una posición y devuelve `false` cuando no quedan filas. Los valores se leen con métodos tipados (`getLong`, `getString`, `getTimestamp`), por nombre de columna o por índice empezando en 1.

Hay una trampa con los nulos: `getLong` y `getInt` devuelven `0` si la columna es `NULL`, porque un primitivo no puede ser nulo. Para distinguirlo tienes que llamar a `wasNull()` justo después, o usar `getObject("col", Long.class)`. Las fechas tienen otra: con JDBC 4.2 puedes pedir directamente `getObject("col", OffsetDateTime.class)` en lugar de pasar por `java.sql.Timestamp`.

Escribir este mapeo para cada tabla es repetitivo y propenso a errores, y en cuanto hay relaciones entre tablas se complica mucho. Ese dolor es exactamente lo que motivará usar `JdbcClient` de Spring o JPA en los días siguientes.

*Mapeo manual con nulos y fechas*

```java
private Tarea mapear(ResultSet rs) throws SQLException {
    long id = rs.getLong("id");
    String titulo = rs.getString("titulo");

    // getLong devolvería 0 para NULL: se pide como objeto
    Long responsableId = rs.getObject("responsable_id", Long.class);

    // JDBC 4.2: tipos de java.time directamente
    OffsetDateTime creadaEn = rs.getObject("creada_en", OffsetDateTime.class);

    boolean completada = rs.getBoolean("completada");
    return new Tarea(id, titulo, responsableId, creadaEn.toInstant(), completada);
}

// Patrón reutilizable: un "RowMapper" casero
@FunctionalInterface
interface MapeadorFila<T> {
    T mapear(ResultSet rs) throws SQLException;
}
```

- **En una línea:** `var tarea = new TareaRecord(rs.getLong("id"), rs.getString("titulo"));`
- **Por qué importa:** Comprender este trabajo artesanal permite entender exactamente qué problema resuelven librerías como MapStruct, JdbcTemplate o Hibernate.
- **Error típico:** Acceder a una columna con el nombre tipográfico incorrecto lanzando `SQLException: column not found`.
- **Java 17 frente a 21:** En 17 y 21 los records facilitan construir el DTO de fila de forma concisa.
- **Se conecta con:** ORM, Spring Data, Data Mapping

### 3. Control Manual de Transacciones con setAutoCommit(false)

Para agrupar varias operaciones SQL en una transacción atómica (todo o nada), se desactiva el autocommit y se llama a `con.commit()` o `con.rollback()`.

**Cómo funciona por dentro.**

Por defecto una conexión JDBC está en modo autocommit: cada sentencia es una transacción que se confirma al terminar. Para que varias sentencias sean atómicas (todas o ninguna) desactivas autocommit, ejecutas las sentencias y llamas a `commit()`. Si algo falla, `rollback()` deshace todo lo pendiente.

El orden importa: el `rollback` debe estar en el `catch`, y antes de devolver la conexión al pool conviene restaurar autocommit, porque otra parte del código recibirá esa misma conexión física. Olvidarlo es un bug clásico que aparece solo en producción.

El nivel de aislamiento (`READ COMMITTED` por defecto en PostgreSQL) decide qué ven las transacciones concurrentes. Para una transferencia, además de la transacción, hay que evitar condiciones de carrera: por ejemplo con `SELECT ... FOR UPDATE` o con una actualización condicional (`UPDATE ... SET saldo = saldo - ? WHERE id = ? AND saldo >= ?`).

*Transferencia atómica con JDBC puro*

```java
public void transferir(long origen, long destino, BigDecimal importe) throws SQLException {
    try (Connection con = dataSource.getConnection()) {
        boolean autoCommitPrevio = con.getAutoCommit();
        con.setAutoCommit(false);
        try (var retirar = con.prepareStatement(
                 "UPDATE cuenta SET saldo = saldo - ? WHERE id = ? AND saldo >= ?");
             var ingresar = con.prepareStatement(
                 "UPDATE cuenta SET saldo = saldo + ? WHERE id = ?")) {

            retirar.setBigDecimal(1, importe);
            retirar.setLong(2, origen);
            retirar.setBigDecimal(3, importe);
            if (retirar.executeUpdate() != 1) {
                throw new SaldoInsuficienteException(origen);   // provoca el rollback
            }

            ingresar.setBigDecimal(1, importe);
            ingresar.setLong(2, destino);
            ingresar.executeUpdate();

            con.commit();
        } catch (SQLException | RuntimeException e) {
            con.rollback();
            throw e;
        } finally {
            con.setAutoCommit(autoCommitPrevio);   // la conexión vuelve limpia al pool
        }
    }
}
```

- **En una línea:** `con.setAutoCommit(false); try { ... con.commit(); } catch { con.rollback(); }`
- **Por qué importa:** Evita estados inconsistentes (e.g. restar dinero de una cuenta pero fallar al sumarlo en otra).
- **Error típico:** Olvidar llamar a `con.rollback()` en el bloque catch dejando transacciones abiertas y bloqueos en la BD.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** ACID, Consistencia de Datos, Spring @Transactional

### 4. Fugas de Conexiones y el Pool de Conexiones

Abrir una conexión TCP a la base de datos es carísimo. En aplicaciones reales se usa un pool (como HikariCP) que mantiene conexiones abiertas y las reutiliza.

**Cómo funciona por dentro.**

Abrir una conexión física a la base de datos es caro: conexión TCP, a menudo TLS, autenticación y creación de un proceso o hilo en el servidor. Hablamos de milisegundos o decenas de milisegundos, frente a microsegundos de una consulta sencilla. Un pool abre un número limitado de conexiones al arrancar y las presta: `getConnection()` toma una libre y `close()` la devuelve en lugar de cerrarla.

Una fuga ocurre cuando el código pide una conexión y nunca llama a `close()`, por ejemplo porque una excepción salta antes. El pool se va vaciando hasta que todas las peticiones quedan esperando y fallan por timeout. HikariCP (el pool por defecto de Spring Boot) puede avisar con `leakDetectionThreshold`.

Más conexiones no es más rendimiento. La base de datos tiene un número limitado de núcleos y discos; con cientos de conexiones activas pasa más tiempo cambiando de contexto que trabajando. La referencia de HikariCP es empezar cerca de `núcleos × 2 + discos` y medir.

*HikariCP configurado en Spring Boot*

```yaml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/taskflow
    username: taskflow
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 10          # conexiones simultáneas máximas
      minimum-idle: 10               # Hikari recomienda pool de tamaño fijo
      connection-timeout: 3000       # ms esperando una conexión libre antes de fallar
      max-lifetime: 1800000          # renueva conexiones antes que el timeout del servidor
      leak-detection-threshold: 20000 # avisa si una conexión lleva 20 s prestada
```

- **En una línea:** `Usar try-with-resources sobre la conexión devuelta por el pool.`
- **Por qué importa:** Sin pool o cerrando mal las conexiones, el servidor agota los sockets del motor de base de datos en minutos.
- **Error típico:** Crear una nueva conexión física con `DriverManager` en cada petición web.
- **Java 17 frente a 21:** HikariCP es el pool por defecto en Spring Boot tanto en Java 17 como en 21.
- **Se conecta con:** HikariCP, Rendimiento, AutoCloseable

**Compruébalo:** Tu aplicación tiene 200 hilos de Tomcat y un pool de 10 conexiones. ¿Qué pasa con 50 peticiones simultáneas que consultan la base de datos?

Diez obtienen conexión y las otras 40 esperan en el pool hasta `connection-timeout`. Si las consultas son rápidas, se van atendiendo por turnos sin problema. Si son lentas, algunas fallarán por timeout. El cuello de botella está en el pool, no en los hilos.

### 5. SQLException y Excepciones Comprobadas

JDBC es una API antigua donde casi todo método lanza `SQLException` (comprobada), obligando a capturarla o declararla.

**Cómo funciona por dentro.**

Casi todos los métodos de JDBC declaran `throws SQLException`, una excepción comprobada. Contiene información útil más allá del mensaje: `getSQLState()` devuelve un código estándar de cinco caracteres (por ejemplo, `23505` es violación de unicidad en PostgreSQL), `getErrorCode()` el código propio del fabricante, y `getNextException()` encadena errores adicionales en operaciones por lotes.

El problema de diseño es que esa excepción comprobada obliga a declarar `throws SQLException` en el servicio, el controlador y cualquier capa intermedia, filtrando un detalle de infraestructura a todo el sistema. Lo habitual es capturarla en el repositorio y traducirla a una excepción de dominio no comprobada, usando el SQLState para distinguir casos.

Spring hace esta traducción automáticamente con su jerarquía `DataAccessException` (`DuplicateKeyException`, `DataIntegrityViolationException`, `CannotAcquireLockException`...), independiente de la base de datos.

*Traducir SQLException a errores de dominio*

```java
private static final String UNIQUE_VIOLATION = "23505";
private static final String FK_VIOLATION = "23503";

public Tarea guardar(Tarea tarea) {
    try (var con = dataSource.getConnection();
         var ps = con.prepareStatement(
             "INSERT INTO tarea (titulo, proyecto_id) VALUES (?, ?)",
             Statement.RETURN_GENERATED_KEYS)) {
        ps.setString(1, tarea.titulo());
        ps.setLong(2, tarea.proyectoId());
        ps.executeUpdate();
        try (var keys = ps.getGeneratedKeys()) {
            keys.next();
            return tarea.conId(keys.getLong(1));
        }
    } catch (SQLException e) {
        throw switch (e.getSQLState()) {
            case UNIQUE_VIOLATION -> new TareaDuplicadaException(tarea.titulo(), e);
            case FK_VIOLATION -> new ProyectoNoExisteException(tarea.proyectoId(), e);
            default -> new ErrorDePersistenciaException("Fallo al guardar la tarea", e);
        };
    }
}
```

- **En una línea:** Envolver `SQLException` en una excepción de dominio propia `RepositorioException`.
- **Por qué importa:** Explica por qué Spring Framework creó su jerarquía de `DataAccessException` no comprobada para envolver JDBC.
- **Error típico:** Propagar `SQLException` hasta los controladores web exponiendo detalles internos de la base de datos.
- **Java 17 frente a 21:** Idéntico en 17 y 21.
- **Se conecta con:** Manejo de Excepciones, Spring DataAccessException, Arquitectura Limpia


## Sesión de 90 minutos

### Bloque 1: Conexión y PreparedStatement (0 - 25 min)

- **Qué estudiar:** Uso de Connection y PreparedStatement con H2 en memoria.
- **Pregunta a responder:** ¿Por qué jamás concatenar cadenas en sentencias SQL?
- **Entregable:** Script que crea una tabla e inserta 3 registros con parámetros tipados.

### Bloque 2: Mapeo Manual con ResultSet (25 - 50 min)

- **Qué estudiar:** Iteración de ResultSet y mapeo a Records inmutables.
- **Pregunta a responder:** ¿Cómo mapear tipos fecha (`Timestamp` a `Instant`) correctamente?
- **Entregable:** Método `List<TareaRecord> listarTodas()` mapeando campos a mano.

### Bloque 3: Transacciones Manuales (50 - 80 min)

- **Qué estudiar:** Manejo de `setAutoCommit(false)`, `commit()` y `rollback()`.
- **Pregunta a responder:** ¿Cómo asegurar que dos inserciones fallen juntas si la segunda tiene error?
- **Entregable:** Test JUnit que fuerce un fallo y verifique que la BD no guardó nada.

### Cierre y Reflexión (80 - 90 min)

- **Qué estudiar:** Reflexión: ¿por qué este trabajo manual justifica usar Spring Data en el Día 9?
- **Pregunta a responder:** Enumerar los 3 dolores del código JDBC artesanal.
- **Entregable:** Checklist del día registrado en la app.


## Explícalo desde cero

### Por qué PreparedStatement previene la inyección SQL

- **Qué entender:** El motor de la base de datos compila la estructura gramatical del SQL primero ("buscar en tabla X por columna Y"). Cuando luego recibe el parámetro, lo trata puramente como dato, sin importar si dentro contiene comillas o sentencias DROP TABLE.
- **Qué suele explicarse mal:** Decir que PreparedStatement "limpia las comillas" (sanitización de texto). No limpia nada; la clave es que compila la consulta antes de recibir los valores.
- **Ejemplo sencillo:** Un formulario impreso con una casilla fija para tu nombre: aunque escribas "Quémese este edificio" en la casilla de tu nombre, el formulario solo guarda tu nombre; nadie va a quemar nada.
- **Señal de alerta:** Si dices que concatenar cadenas es seguro si "escapas" las comillas a mano.


## Preguntas

### Nivel 1 (Básico): ¿Por qué `ResultSet.next()` debe llamarse antes de leer la primera fila de resultados?

Porque el cursor de ResultSet se posiciona inicialmente antes de la primera fila. `next()` avanza el cursor a la primera fila válida y devuelve false cuando no quedan más filas.

**Respuesta razonada:** Porque el cursor de un `ResultSet` se coloca inicialmente antes de la primera fila. `next()` lo mueve a la siguiente fila y devuelve `true` si existe. Este diseño permite un único patrón para cualquier número de filas, incluido cero: `while (rs.next()) { ... }`. Si el resultado está vacío, el bucle no se ejecuta.

Leer sin haber llamado a `next()` lanza `SQLException` («ResultSet not positioned properly» o similar). Para una consulta que debe devolver como mucho una fila, se usa `if (rs.next())` y, si importa, se comprueba que no haya una segunda.

### Nivel 2 (Intermedio): Si cierras una `Connection` dentro de un try-with-resources, ¿qué pasa con los `PreparedStatement` creados a partir de ella?

La especificación JDBC establece que cerrar una conexión cierra automáticamente sus sentencias y result sets asociados, aunque cerrarlos explícitamente en el try es la mejor práctica.

**Respuesta razonada:** Según la especificación JDBC, cerrar un `Statement` cierra su `ResultSet`, y cerrar una `Connection` libera los recursos asociados, incluidos sus statements. Con una conexión física eso es suficiente.

Pero con un pool, `close()` no cierra la conexión física: solo la devuelve. Los pools como HikariCP rastrean los statements abiertos y los cierran al devolver la conexión, pero no todos los pools ni drivers lo hacen igual, y mientras tanto mantienes cursores abiertos en la base de datos. La práctica correcta es declarar `Connection`, `PreparedStatement` y `ResultSet` en try-with-resources, que los cierra en orden inverso sin depender del pool.

### Nivel 3 (Entrevista Senior): ¿Cómo impactan los Virtual Threads de Java 21 en las llamadas bloqueantes de JDBC comparado con Java 17?

En Java 17, una llamada JDBC bloqueante mantiene ocupado un hilo pesado del SO (~1MB). En Java 21, el Virtual Thread se suspende (unmount) del hilo físico portador (Carrier Thread) mientras la BD responde, permitiendo atender miles de peticiones bloqueantes simultáneas con poquísimos hilos del SO.

**Respuesta razonada:** JDBC es bloqueante: el hilo espera a que la base de datos responda. Con Java 17 y hilos de plataforma, cada petición en espera ocupa un hilo del sistema operativo (alrededor de 1 MB de pila), así que el número de peticiones concurrentes está limitado por el tamaño del pool de hilos.

Con Virtual Threads de Java 21, cuando un hilo virtual se bloquea en E/S, la JVM lo desmonta de su hilo portador y este queda libre para otro hilo virtual. Puedes tener miles de peticiones esperando a la base de datos con muy pocos hilos reales. Dos matices: los drivers antiguos que usaban `synchronized` alrededor de la E/S provocaban pinning (el hilo virtual no se desmonta); el driver de PostgreSQL lo corrigió en la versión 42.6. Y la base de datos sigue siendo el límite: el pool de conexiones decide cuántas consultas se ejecutan a la vez, así que mejora el rendimiento de la aplicación, no de la base de datos.


## Reto del tutor

**Reto:** Un candidato dice: "En mi app hago `DriverManager.getConnection(...)` en cada consulta porque así me aseguro de tener una conexión fresca y limpia". Evalúa esta respuesta.

- **Lo que está bien:** Reconoce que cada consulta necesita una conexión limpia.
- **Respuesta superficial:** Decir que "es lento" sin explicar por qué.
- **Lo que falta de fondo:** No entender el handshake TCP, autenticación de usuario y consumo de descriptores en el servidor de base de datos.
- **Respuesta senior:** Es catastrófico para producción: crear una conexión con `DriverManager` implica un handshake TCP, negociación TLS y autenticación en la BD (tardando entre 20ms y 100ms por consulta). Bajo 200 usuarios concurrentes, el servidor colapsará por agotamiento de conexiones. La solución obligatoria es un pool como HikariCP, que mantiene conexiones precalentadas y las presta en microsegundos.


## Katas

### Kata 9.1: CRUD con JDBC puro en H2

Implementa una clase `TareaJdbcRepositorio` con métodos `guardar(Tarea t)` y `buscarPorId(Long id)` usando puramente JDBC y validando el cierre de recursos con try-with-resources.


## Reglas de oro

- Usa SIEMPRE `PreparedStatement` con comodines `?`.
- Usa siempre try-with-resources para cerrar Connection, Statement y ResultSet.
- En producción usa siempre un pool de conexiones como HikariCP.


## Java 17 frente a Java 21

| Característica | Java 17 | Java 21 |
| --- | --- | --- |
| Comportamiento en Virtual Threads | Bloquea hilo del SO | Suspende Virtual Thread liberando hilo físico |


## Proyecto guiado «Pedidos»: Persistir pedidos con JDBC

Guardar pedidos y líneas en una base de datos relacional y sentir el coste del mapeo manual.

**Tareas:**

- Crea las tablas `clientes`, `productos`, `pedidos` y `lineas_pedido` en H2 con un script SQL.
- Implementa `PedidoRepositoryJdbc` con `PreparedStatement` para guardar y buscar pedidos.
- Guarda un pedido y sus líneas en una sola transacción con `setAutoCommit(false)` y `rollback()` si algo falla.

**Está listo cuando:**

- Si falla la inserción de una línea, no queda en la base de datos un pedido a medias.
- Ninguna consulta concatena valores en el SQL.


## Fuentes oficiales

- [Tutorial de Java: JDBC](https://docs.oracle.com/javase/tutorial/jdbc/basics/index.html)
- [Documentación del driver JDBC de PostgreSQL](https://jdbc.postgresql.org/documentation/)

