Acceso a Bases de Datos con JDBC y SQL Seguro

En la lección Archivos, Serialización y Empaquetado JAR guardaste datos en archivos. Funciona, pero probá lo siguiente con un archivo: buscar todos los productos con stock menor a 10, ordenados por precio, mientras otros tres usuarios están escribiendo al mismo tiempo.

Eso es exactamente lo que hace una base de datos relacional, y por eso existe. JDBC es el puente entre tu código Java y ella.


1. La arquitectura: por qué hay una capa en el medio

Capas entre el código de la aplicación y el motor de base de datos Tu código — ProductoDAO, ClienteDAO Habla en objetos: guardar(producto), buscarPorId(4). No sabe qué motor hay abajo. java.sql — la API estándar de JDBC Connection, PreparedStatement, ResultSet. Son INTERFACES: Clases Abstractas, Interfaces y Organización del Código en estado puro. driver PostgreSQL implementa las interfaces driver MySQL implementa las interfaces driver H2 / SQLite implementa las interfaces Cambiar de PostgreSQL a MySQL es cambiar una dependencia y una URL. Tu código no se entera. Es exactamente el beneficio de programar contra interfaces, aplicado a escala de industria.
JDBC es un contrato; cada fabricante escribe su implementación. La lección [Clases Abstractas, Interfaces y Organización del Código](/cursos/java/09-clases-abstractas-interfaces-y-modelado) explicaba por qué esto vale la pena — acá se ve el resultado.
// La conexión: URL, usuario, contraseña
String url = "jdbc:postgresql://localhost:5432/tienda";

try (Connection conn = DriverManager.getConnection(url, "usuario", "clave")) {
    System.out.println("Conectado a " + conn.getMetaData().getDatabaseProductName());
}

Fijate el try-with-resources de la lección Manejo de Excepciones y Robustez. Una conexión no cerrada es un recurso perdido, y con suficientes de ellas la base rechaza conexiones nuevas y la aplicación entera se cae.


2. Statement vs PreparedStatement: la vulnerabilidad más famosa del mundo

Esta es la parte más importante de la lección. Mirá este código, que parece inofensivo:

// NUNCA HAGAS ESTO
String email = pedirAlUsuario();
String sql = "SELECT * FROM usuarios WHERE email = '" + email + "'";
ResultSet rs = statement.executeQuery(sql);
Cómo una entrada maliciosa se convierte en SQL ejecutable con concatenación de cadenas Concatenando la entrada del usuario — el dato se vuelve CÓDIGO El usuario escribe en el formulario: ' OR '1'='1 Tu código arma: "SELECT * FROM usuarios WHERE email = '" + entrada + "'" La base recibe: SELECT * FROM usuarios WHERE email = '' OR '1'='1' '1'='1' es siempre verdadero → devuelve TODOS los usuarios de la tabla. Con PreparedStatement — el dato NUNCA es código 1. Se envía primero la plantilla: SELECT * FROM usuarios WHERE email = ? 2. La base la compila y fija su estructura. Ya sabe que hay UN parámetro y dónde va. 3. El valor viaja aparte, como DATO. Busca literalmente el email ' OR '1'='1 No encuentra a nadie, que es exactamente lo correcto. El ataque desaparece por construcción.
La diferencia no es que PreparedStatement "escape comillas": es que la estructura de la consulta se fija antes de que el dato exista. No hay nada que escapar.
// SIEMPRE ASÍ
String sql = "SELECT id, nombre, precio FROM productos WHERE categoria = ? AND precio < ?";

try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, categoria);     // los índices arrancan en 1, no en 0
    ps.setDouble(2, precioMaximo);

    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            System.out.println(rs.getString("nombre") + " — $" + rs.getDouble("precio"));
        }
    }
}

Además de la seguridad, PreparedStatement te da dos cosas gratis:

  • Rendimiento: la base compila el plan de ejecución una vez y lo reutiliza para cada valor distinto.
  • Tipado: setDouble, setDate, setBoolean se encargan del formato. Nunca más pelear con el formato de fechas dentro de un string SQL.

El índice de los parámetros arranca en 1. Es una de las poquísimas cosas en Java que no empieza en cero, y una fuente garantizada de SQLException la primera vez.


3. Recorrer resultados y modificar datos

Un ResultSet es un cursor: arranca antes de la primera fila, y next() avanza y devuelve false cuando se acabaron.

List<Producto> productos = new ArrayList<>();

try (PreparedStatement ps = conn.prepareStatement("SELECT id, nombre, precio, stock FROM productos");
     ResultSet rs = ps.executeQuery()) {

    while (rs.next()) {                       // ← sin este while, no leés nada
        productos.add(new Producto(
            rs.getLong("id"),
            rs.getString("nombre"),
            rs.getDouble("precio"),
            rs.getInt("stock")
        ));
    }
}

Para modificar datos se usa executeUpdate(), que devuelve cuántas filas se afectaron:

// INSERT, recuperando el id que genera la base
String sql = "INSERT INTO productos (nombre, precio, stock) VALUES (?, ?, ?)";

try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
    ps.setString(1, "Yerba Playadito");
    ps.setDouble(2, 3200.00);
    ps.setInt(3, 45);

    int filas = ps.executeUpdate();
    System.out.println("Insertadas: " + filas);

    try (ResultSet claves = ps.getGeneratedKeys()) {
        if (claves.next()) {
            System.out.println("Id asignado: " + claves.getLong(1));
        }
    }
}

// UPDATE — el WHERE no es opcional
try (PreparedStatement ps = conn.prepareStatement(
         "UPDATE productos SET stock = stock - ? WHERE id = ? AND stock >= ?")) {
    ps.setInt(1, cantidad);
    ps.setLong(2, id);
    ps.setInt(3, cantidad);        // la condición evita dejar el stock negativo

    if (ps.executeUpdate() == 0) {
        throw new IllegalStateException("Sin stock suficiente o el producto no existe");
    }
}

Fijate ese if (executeUpdate() == 0): el valor de retorno es información, no ruido. Un UPDATE que afecta cero filas casi siempre significa que algo no salió como esperabas.


4. Transacciones: todo o nada

Por defecto, JDBC está en autocommit: cada sentencia se confirma sola, apenas se ejecuta. Para una operación suelta está bien. Para dos que dependen entre sí, es una bomba.

Una transferencia bancaria con autocommit frente a la misma dentro de una transacción autocommit = true (el valor por defecto) UPDATE cuentas SET saldo = saldo - 5000 ... ✓ confirmado al instante, ya no hay vuelta atrás UPDATE cuentas SET saldo = saldo + 5000 ... ✗ falla: se cortó la red Resultado: el dinero salió de una cuenta y no llegó a la otra. Se evaporó. Y no hay ninguna excepción que lo arregle: el primer UPDATE ya está confirmado en disco. conn.setAutoCommit(false) — una transacción UPDATE ... saldo - 5000 pendiente, todavía no confirmado UPDATE ... saldo + 5000 ✗ falla catch → conn.rollback() — la base deshace TODO, incluido el primer UPDATE Resultado: las dos cuentas quedan como estaban. O pasan las dos cosas, o no pasa ninguna.
Una transacción no es "por si acaso": es la única forma de que dos operaciones dependientes no puedan quedar a medio camino.
Connection conn = obtenerConexion();
try {
    conn.setAutoCommit(false);              // 1. abrimos la transacción

    debitar(conn, cuentaOrigen, monto);
    acreditar(conn, cuentaDestino, monto);

    conn.commit();                          // 2. las dos salieron bien: confirmamos

} catch (SQLException e) {
    conn.rollback();                        // 3. algo falló: deshacemos todo
    throw new TransferenciaFallidaException("No se pudo transferir", e);  // ver Manejo de Excepciones y Robustez
} finally {
    conn.setAutoCommit(true);               // 4. dejamos la conexión como la encontramos
    conn.close();
}

Ese finally con setAutoCommit(true) importa más de lo que parece: si la conexión viene de un pool —y en producción siempre viene de un pool—, la próxima persona que la use la recibe con la configuración que vos dejaste.


5. Conexiones y pools

Abrir una conexión es caro: resolución de DNS, handshake TCP, autenticación, negociación de protocolo. Fácilmente decenas de milisegundos. Hacerlo por cada consulta es inviable.

Por eso en producción se usa un pool de conexiones: un conjunto de conexiones ya abiertas que se prestan y se devuelven.

// HikariCP — el estándar de facto, y el que trae Spring Boot por defecto
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/tienda");
config.setUsername("usuario");
config.setPassword("clave");
config.setMaximumPoolSize(10);

DataSource dataSource = new HikariDataSource(config);

// conn.close() NO cierra la conexión: la devuelve al pool
try (Connection conn = dataSource.getConnection()) {
    // ...
}

Con el pool, close() cambia de significado: devuelve la conexión en lugar de destruirla. Por eso seguís usando try-with-resources igual que siempre; simplemente hace algo distinto por debajo.

Un Connection no es seguro entre hilos. Si tu aplicación es concurrente —y con la lección Programación Concurrente: Hilos, Sincronización y Pools ya sabés lo que eso implica—, cada hilo pide la suya al pool y la devuelve al terminar. Nunca compartas una Connection entre hilos.


6. El patrón DAO

Mezclar SQL con lógica de negocio se vuelve inmanejable rápido. El patrón DAO (Data Access Object) concentra todo el acceso a datos de una entidad en una clase:

public interface ProductoDAO {                       // la interfaz: el contrato
    Optional<Producto> buscarPorId(long id);
    List<Producto> buscarPorCategoria(String categoria);
    long insertar(Producto producto);
    boolean actualizarStock(long id, int delta);
    boolean eliminar(long id);
}

Con esa interfaz, el servicio de negocio no sabe si abajo hay PostgreSQL, un archivo o un mapa en memoria para los tests. Es el mismo principio de las lecciones Clases Abstractas, Interfaces y Organización del Código y TAD Lista: Estáticas, Dinámicas y Enlazadas: programar contra el contrato.


7. Errores frecuentes

ErrorQué pasaCómo se arregla
Concatenar entradas del usuario en el SQLInyección SQL: cualquiera puede leer, modificar o borrar toda la base.PreparedStatement con ?, siempre y sin excepciones.
No cerrar Connection, PreparedStatement o ResultSetFuga de recursos; la base termina rechazando conexiones nuevas.try-with-resources anidado.
Índices de parámetros empezando en 0SQLException sobre un índice fuera de rango.Los parámetros de JDBC arrancan en 1.
Dejar autocommit en operaciones de varios pasosEstados a medio camino imposibles de reparar.setAutoCommit(false), commit() y rollback().
No devolver la conexión a su estado originalLa siguiente persona que la toma del pool hereda tu configuración.Restaurar setAutoCommit(true) en el finally.
SELECT * en código de producciónSe rompe al agregar o reordenar columnas, y trae datos que no usás.Nombrar las columnas explícitamente.
Ignorar lo que devuelve executeUpdate()Un UPDATE que no afectó ninguna fila pasa por exitoso.Chequear el retorno y actuar cuando es 0.
Compartir una Connection entre hilosCorrupción de datos y errores impredecibles.Una conexión por hilo, tomada del pool.
Abrir una conexión por cada consultaLatencia enorme y la base saturada de conexiones.Un pool (HikariCP).

8. Ejercicio práctico guiado

Desafío: DAO de productos

  1. Creá la tabla productos y un DAO con las cinco operaciones CRUD.
  2. Usá PreparedStatement en todas, sin una sola concatenación.
  3. buscarPorId devuelve Optional<Producto>, no null.
  4. descontarStock usa una transacción y falla si no hay stock suficiente.
  5. Insertá tres productos y verificá que todo funciona.
Ver solución sugerida
import java.sql.*;
import java.util.*;

public record Producto(Long id, String nombre, double precio, int stock) { }

public class ProductoDAOJdbc {

    private final javax.sql.DataSource dataSource;

    public ProductoDAOJdbc(javax.sql.DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void crearTablaSiNoExiste() throws SQLException {
        String ddl = """
            CREATE TABLE IF NOT EXISTS productos (
                id      BIGINT AUTO_INCREMENT PRIMARY KEY,
                nombre  VARCHAR(120)  NOT NULL,
                precio  DECIMAL(12,2) NOT NULL CHECK (precio >= 0),
                stock   INT           NOT NULL CHECK (stock  >= 0)
            )
            """;
        try (Connection conn = dataSource.getConnection();
             Statement st = conn.createStatement()) {
            st.execute(ddl);   // DDL fijo, sin datos del usuario: Statement es seguro acá
        }
    }

    // ── CREATE ──────────────────────────────────────────────────
    public long insertar(Producto p) throws SQLException {
        String sql = "INSERT INTO productos (nombre, precio, stock) VALUES (?, ?, ?)";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {

            ps.setString(1, p.nombre());     // índice 1, no 0
            ps.setDouble(2, p.precio());
            ps.setInt(3, p.stock());
            ps.executeUpdate();

            try (ResultSet claves = ps.getGeneratedKeys()) {
                if (claves.next()) return claves.getLong(1);
                throw new SQLException("La base no devolvió el id generado");
            }
        }
    }

    // ── READ ────────────────────────────────────────────────────
    public Optional<Producto> buscarPorId(long id) throws SQLException {
        // Columnas explícitas: si mañana se agrega una, esto no se rompe
        String sql = "SELECT id, nombre, precio, stock FROM productos WHERE id = ?";

        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {

            ps.setLong(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                // Optional en lugar de null: ver Manejo de Excepciones y Robustez
                return rs.next() ? Optional.of(mapear(rs)) : Optional.empty();
            }
        }
    }

    public List<Producto> buscarConStockMenorA(int umbral) throws SQLException {
        String sql = """
            SELECT id, nombre, precio, stock
            FROM productos
            WHERE stock < ?
            ORDER BY stock ASC, nombre ASC
            """;
        List<Producto> resultado = new ArrayList<>();

        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {

            ps.setInt(1, umbral);
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) resultado.add(mapear(rs));
            }
        }
        return resultado;
    }

    // ── UPDATE, dentro de una transacción ───────────────────────
    public void descontarStock(long id, int cantidad) throws SQLException {
        if (cantidad <= 0) {
            throw new IllegalArgumentException("La cantidad debe ser positiva");
        }

        // El WHERE incluye stock >= ? : la base garantiza que no quede negativo,
        // incluso si dos hilos ejecutan esto al mismo tiempo.
        String sql = "UPDATE productos SET stock = stock - ? WHERE id = ? AND stock >= ?";

        Connection conn = dataSource.getConnection();
        try {
            conn.setAutoCommit(false);

            try (PreparedStatement ps = conn.prepareStatement(sql)) {
                ps.setInt(1, cantidad);
                ps.setLong(2, id);
                ps.setInt(3, cantidad);

                if (ps.executeUpdate() == 0) {
                    // Cero filas afectadas: o no existe, o no alcanza el stock
                    throw new SQLException("Stock insuficiente o producto inexistente: " + id);
                }
            }

            registrarMovimiento(conn, id, -cantidad);   // segunda operación, misma transacción
            conn.commit();

        } catch (SQLException e) {
            conn.rollback();   // deshace las DOS operaciones
            throw e;
        } finally {
            conn.setAutoCommit(true);   // devolvemos la conexión como la encontramos
            conn.close();               // con pool, esto la devuelve, no la destruye
        }
    }

    private void registrarMovimiento(Connection conn, long productoId, int delta)
            throws SQLException {
        String sql = "INSERT INTO movimientos (producto_id, delta) VALUES (?, ?)";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setLong(1, productoId);
            ps.setInt(2, delta);
            ps.executeUpdate();
        }
        // Sin conn.commit() acá: la transacción la controla quien llamó
    }

    // ── DELETE ──────────────────────────────────────────────────
    public boolean eliminar(long id) throws SQLException {
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement("DELETE FROM productos WHERE id = ?")) {
            ps.setLong(1, id);
            return ps.executeUpdate() > 0;   // el retorno dice si borró algo
        }
    }

    private Producto mapear(ResultSet rs) throws SQLException {
        return new Producto(
            rs.getLong("id"),
            rs.getString("nombre"),
            rs.getDouble("precio"),
            rs.getInt("stock")
        );
    }

    public static void main(String[] args) throws Exception {
        // H2 en memoria: no hace falta instalar nada para probar
        com.zaxxer.hikari.HikariConfig config = new com.zaxxer.hikari.HikariConfig();
        config.setJdbcUrl("jdbc:h2:mem:tienda;DB_CLOSE_DELAY=-1");
        config.setMaximumPoolSize(5);

        try (var ds = new com.zaxxer.hikari.HikariDataSource(config)) {
            ProductoDAOJdbc dao = new ProductoDAOJdbc(ds);
            dao.crearTablaSiNoExiste();

            long idYerba = dao.insertar(new Producto(null, "Yerba Playadito", 3200.00, 45));
            dao.insertar(new Producto(null, "Café molido",  5800.50,  8));
            dao.insertar(new Producto(null, "Azúcar 1kg",   1150.00,  3));

            System.out.println("Buscado: " + dao.buscarPorId(idYerba).orElseThrow());
            System.out.println("Inexistente: " + dao.buscarPorId(9999));   // Optional.empty
            System.out.println("Stock bajo: " + dao.buscarConStockMenorA(10));

            dao.descontarStock(idYerba, 5);
            System.out.println("Tras descontar 5: " + dao.buscarPorId(idYerba).orElseThrow());

            try {
                dao.descontarStock(idYerba, 1000);   // más de lo que hay
            } catch (SQLException e) {
                System.out.println("Rechazado correctamente: " + e.getMessage());
            }
            System.out.println("Intacto tras el rollback: " +
                dao.buscarPorId(idYerba).orElseThrow());
        }
    }
}

Tres decisiones que valen más que el código.

WHERE id = ? AND stock >= ? no es una comodidad: es lo que hace que la operación sea segura ante concurrencia. Si dos hilos intentan descontar el último producto al mismo tiempo, la base resuelve el conflicto; uno de los dos recibe cero filas afectadas y falla limpio. Con un SELECT seguido de un UPDATE, los dos verían stock disponible y el resultado quedaría negativo.

registrarMovimiento recibe la Connection como parámetro y no hace commit. Así puede participar de la transacción de quien la llame. Si abriera su propia conexión, quedaría fuera del rollback y tendrías un movimiento registrado de un descuento que nunca ocurrió.

Y buscarPorId devuelve Optional<Producto>. “No existe” no es un error excepcional, es un resultado posible. Devolver null traslada el problema al que llama, que se va a olvidar de chequearlo.


Para llevarte

  • JDBC es un conjunto de interfaces; cada motor trae su driver. Cambiar de base es cambiar una dependencia.
  • Nunca concatenes entradas del usuario en un SQL. PreparedStatement con ? no escapa comillas: fija la estructura antes de que el dato exista.
  • Los índices de parámetros empiezan en 1.
  • Connection, PreparedStatement y ResultSet son AutoCloseable: try-with-resources anidado, siempre.
  • Con autocommit, dos operaciones dependientes pueden quedar a medio camino. Para eso está setAutoCommit(false) con commit/rollback.
  • Restaurá el estado de la conexión en el finally: con pool, la hereda el siguiente.
  • Una Connection no es segura entre hilos. Una por hilo, tomada del pool.
  • El valor que devuelve executeUpdate() es información: cero filas afectadas casi nunca es lo que esperabas.
  • El patrón DAO aísla el SQL en una clase por entidad, detrás de una interfaz.