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
// 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);
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,setBooleanse 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
SQLExceptionla 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.
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
Connectionno 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 unaConnectionentre 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
| Error | Qué pasa | Cómo se arregla |
|---|---|---|
| Concatenar entradas del usuario en el SQL | Inyección SQL: cualquiera puede leer, modificar o borrar toda la base. | PreparedStatement con ?, siempre y sin excepciones. |
No cerrar Connection, PreparedStatement o ResultSet | Fuga de recursos; la base termina rechazando conexiones nuevas. | try-with-resources anidado. |
| Índices de parámetros empezando en 0 | SQLException sobre un índice fuera de rango. | Los parámetros de JDBC arrancan en 1. |
| Dejar autocommit en operaciones de varios pasos | Estados a medio camino imposibles de reparar. | setAutoCommit(false), commit() y rollback(). |
| No devolver la conexión a su estado original | La siguiente persona que la toma del pool hereda tu configuración. | Restaurar setAutoCommit(true) en el finally. |
SELECT * en código de producción | Se 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 hilos | Corrupción de datos y errores impredecibles. | Una conexión por hilo, tomada del pool. |
| Abrir una conexión por cada consulta | Latencia enorme y la base saturada de conexiones. | Un pool (HikariCP). |
8. Ejercicio práctico guiado
Desafío: DAO de productos
- Creá la tabla
productosy un DAO con las cinco operaciones CRUD. - Usá
PreparedStatementen todas, sin una sola concatenación. buscarPorIddevuelveOptional<Producto>, nonull.descontarStockusa una transacción y falla si no hay stock suficiente.- 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.
PreparedStatementcon?no escapa comillas: fija la estructura antes de que el dato exista. - Los índices de parámetros empiezan en 1.
Connection,PreparedStatementyResultSetsonAutoCloseable:try-with-resourcesanidado, siempre.- Con autocommit, dos operaciones dependientes pueden quedar a medio camino. Para eso está
setAutoCommit(false)concommit/rollback. - Restaurá el estado de la conexión en el
finally: con pool, la hereda el siguiente. - Una
Connectionno 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.