Día 9 de 30 Fase 2: De Vanilla a la Necesidad de Herramientas

Acceso a Datos Artesanal con JDBC: El Dolor del SQL Manual

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

Para dominar. Al terminar deberías poder hacerlo sin ayuda.

  • 4h de estudio
  • 5 conceptos
  • 3 preguntas
  • 1 katas
Java 17 LTSJava 21 LTSJDBCPreparedStatementSQL InjectionTransacciones
Al terminar este día sabrás 4
  • 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.

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.

En una línea ps.setString(1, email); garantizando inmunidad a SQL Injection.

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;
        }
    }
}
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.

Evita la vulnerabilidad de seguridad más destructiva de la historia del software web.

Se conecta con SeguridadRendimiento SQLJDBC

Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.

  1. 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?

    Resultado esperado. Script que crea una tabla e inserta 3 registros con parámetros tipados.

  2. 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?

    Resultado esperado. Método List<TareaRecord> listarTodas() mapeando campos a mano.

  3. 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?

    Resultado esperado. Test JUnit que fuerce un fallo y verifique que la BD no guardó nada.

  4. 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.

    Resultado esperado. Checklist del día registrado en la app.

Tres formas de comprobar si lo has entendido, de menos a más exigente: explicarlo con tus palabras, responder preguntas trampa y resolver el reto del tutor.

Explícalo desde cero

Lee cada ficha y explícala en voz alta antes de seguir: si puedes contárselo a alguien que empieza, lo has entendido.

Por qué PreparedStatement previene la inyección SQL

Lo que debes 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.

Analogía para contarlo

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.

Lo que 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.

Señal de que aún no lo dominas

Si dices que concatenar cadenas es seguro si "escapas" las comillas a mano.

Preguntas trampa

Responde de memoria, en voz alta o por escrito, y después abre la respuesta razonada: lo importante es el porqué, no la palabra exacta.

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

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.

Qué está evaluando la pregunta

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.

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

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.

Qué está evaluando la pregunta

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.

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

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.

Qué está evaluando la pregunta

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.

Reto del tutor

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 una respuesta suele tener bien

Reconoce que cada consulta necesita una conexión limpia.

Lo que suele quedarse en la superficie

Decir que "es lento" sin explicar por qué.

El concepto de fondo que suele faltar

No entender el handshake TCP, autenticación de usuario y consumo de descriptores en el servidor de base de datos.

Cómo lo respondería alguien senior en una entrevista

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.

Proyecto «Pedidos» · paso 9 de 30

Persistir pedidos con JDBC

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

De qué va el proyecto

El backend de una tienda online: clientes, catálogo de productos y pedidos con sus líneas, pagos y envíos. Empieza como un proyecto Maven con records y termina como un sistema con API REST, seguridad, mensajería, contenedores y observabilidad. Cada paso construye sobre el anterior, así que trabaja siempre en el mismo repositorio.

Qué hacer

  1. Crea las tablas clientes, productos, pedidos y lineas_pedido en H2 con un script SQL.
  2. Implementa PedidoRepositoryJdbc con PreparedStatement para guardar y buscar pedidos.
  3. 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.

Katas: ejercicios cortos

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ísticaJava 17 LTSJava 21 LTS
Comportamiento en Virtual Threads Bloquea hilo del SO Suspende Virtual Thread liberando hilo físico
Se guarda automáticamente

Fuentes oficiales

Revisado el . Este día en Markdown