Módulo 12: JDBC: fundamentos

Objetivos del módulo

  • Entender qué es JDBC, cómo está organizado (API, DriverManager, driver) y qué tipos de drivers existen
  • Levantar MySQL 8.4 con Docker y cargar la base de datos de ejemplo EMPRESA
  • Añadir el driver MySQL Connector/J a un proyecto Maven y conectarte con DriverManager y try-with-resources
  • Leer la configuración de la conexión desde un fichero de propiedades o variables de entorno
  • Ejecutar SQL con Statement (executeQuery, executeUpdate, execute) y recorrer un ResultSet
  • Convertir correctamente los tipos SQL a Java, tratar los NULL y trabajar con fechas de java.time
  • Comprender la inyección SQL viéndola en acción y evitarla con PreparedStatement
  • Insertar filas y recuperar las claves generadas, actualizar y borrar
  • Mapear filas a records (Empleado, Departamento…), hacer JOIN, llamar a procedimientos, consultar metadatos y usar ResultSet desplazables
  • Interpretar una SQLException (código de error, SQLState) y explorar la base de datos con IntelliJ IDEA, DBeaver o MySQL Workbench

1. ¿Qué es JDBC?

📘 Concepto: JDBC (Java Database Connectivity) es la API estándar de Java para trabajar con bases de datos relacionales. Define un conjunto de interfaces (Connection, Statement, ResultSet…) en los paquetes java.sql y javax.sql. Cada fabricante de bases de datos proporciona un driver: una biblioteca que implementa esas interfaces y sabe hablar con su servidor.

Piensa en los enchufes cuando viajas. Tu cargador (tu programa) siempre tiene la misma clavija (la API JDBC). En cada país necesitas un adaptador distinto (el driver) que convierte esa clavija en la del enchufe local (el protocolo de red de MySQL, PostgreSQL, Oracle…). Si mañana cambias de país, cambias de adaptador, no de cargador.

¿Por qué es importante JDBC si después usaremos Hibernate y Spring Data? Porque todo el acceso a datos relacionales en Java acaba pasando por JDBC: Hibernate genera SQL y lo ejecuta con JDBC, Spring Data JPA usa Hibernate, y el JdbcTemplate de Spring es una capa fina sobre JDBC. Cuando algo falla (una conexión que no se cierra, un NULL mal tratado, una transacción a medias), entender JDBC es lo que te permite saber qué está pasando.

1.1 Arquitectura

Arquitectura de JDBC: la aplicación usa la API JDBC, DriverManager elige el driver y el driver habla con la base de datos

Figura 1. Arquitectura de JDBC: aplicación → API JDBC → DriverManager → driver → base de datos.

Pieza Qué es Quién la proporciona
API JDBC Interfaces y clases de java.sql / javax.sql El JDK (módulo java.sql)
DriverManager Clase que registra los drivers disponibles y elige el adecuado según la URL El JDK
DataSource Alternativa moderna a DriverManager, base de los pools de conexiones (módulo 13) El JDK (interfaz) + el driver o el pool
Driver Implementación concreta de Connection, Statement, ResultSet… El fabricante (un .jar que añades con Maven)
Servidor El sistema gestor de bases de datos MySQL, PostgreSQL, Oracle…

Las interfaces que más vas a usar:

Interfaz Para qué sirve
Connection Una sesión abierta con la base de datos. Crea sentencias y controla las transacciones
Statement Ejecuta SQL “tal cual”, sin parámetros
PreparedStatement Ejecuta SQL precompilado con parámetros ?. Es la que usarás casi siempre
CallableStatement Llama a procedimientos almacenados
ResultSet El resultado de una consulta: un cursor que recorre las filas
DatabaseMetaData / ResultSetMetaData Información sobre la base de datos y sobre las columnas de un resultado
SQLException Excepción comprobada que lanza casi cualquier método de JDBC

💡 Consejo: Tu código solo debe importar tipos de java.sql y javax.sql, nunca clases del paquete del driver (com.mysql.cj...). Así, cambiar de MySQL a PostgreSQL es cambiar la dependencia y la URL, no reescribir el programa (otra cosa es que el SQL que escribas sea compatible con ambos).

1.2 Tipos de drivers

La especificación clasifica los drivers en cuatro tipos según cómo llegan hasta la base de datos:

Los cuatro tipos de drivers JDBC: puente ODBC, API nativa, middleware de red y 100 % Java

Figura 2. Los cuatro tipos de drivers JDBC. Hoy se usa casi exclusivamente el tipo 4.

📘 Concepto: Un driver de tipo 4 es un simple .jar: no hay que instalar nada en el sistema operativo. Por eso basta con declararlo como dependencia de Maven y funciona igual en Windows, Linux, macOS o dentro de un contenedor Docker.

💡 Consejo: La versión de la especificación JDBC incluida en Java 25 es la 4.3 (desde Java 9). Cada driver declara la que implementa: Connector/J informa de la 4.2, suficiente para todo el módulo (incluido el soporte de java.time, que llegó precisamente en la 4.2).


2. Preparar el entorno: MySQL con Docker y la base de datos EMPRESA

Vamos a trabajar con la misma base de datos EMPRESA del Curso de Bases de Datos, adaptada a MySQL. Tiene departamentos, empleados (con su jefe), clientes, categorías, productos, pedidos con sus líneas, proyectos y asignaciones de empleados a proyectos. Los scripts están en la carpeta de recursos del curso:

2.1 Arrancar MySQL 8.4 en un contenedor

Con Docker no tienes que instalar MySQL en tu equipo: descargas la imagen oficial y arrancas un contenedor. Desde la carpeta del curso (la que contiene recursos/), ejecuta en una sola línea:

docker run -d --name mysql-empresa -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=empresa -e MYSQL_USER=curso -e MYSQL_PASSWORD=curso -v mysql-empresa-datos:/var/lib/mysql -v "$PWD/recursos:/docker-entrypoint-initdb.d:ro" mysql:8.4
Opción Significado
-d Ejecuta el contenedor en segundo plano
--name mysql-empresa Nombre para referirte a él (docker stop mysql-empresa, docker start mysql-empresa…)
-p 3306:3306 Publica el puerto 3306 del contenedor en el 3306 de tu equipo: tu programa se conecta a localhost:3306
-e MYSQL_ROOT_PASSWORD=root Contraseña del administrador root (obligatoria)
-e MYSQL_DATABASE=empresa, MYSQL_USER=curso, MYSQL_PASSWORD=curso Al iniciar por primera vez, crea la base de datos empresa y el usuario curso con todos los permisos sobre ella
-v mysql-empresa-datos:/var/lib/mysql Guarda los datos en un volumen: sobreviven aunque borres el contenedor
-v "$PWD/recursos:/docker-entrypoint-initdb.d:ro" Monta los scripts del curso en la carpeta que la imagen ejecuta la primera vez que arranca, en orden alfabético (borrar → crear → datos)
mysql:8.4 Imagen oficial de MySQL 8.4 LTS

⚠️ Importante: En PowerShell $PWD también funciona; en el CMD clásico de Windows escribe la ruta completa (-v "C:\ruta\al\curso\recursos:/docker-entrypoint-initdb.d:ro"). Los scripts de docker-entrypoint-initdb.d solo se ejecutan cuando el volumen de datos está vacío, es decir, la primera vez. Si ya habías creado el contenedor sin ellos, cárgalos a mano (apartado 2.2) o borra contenedor y volumen (docker rm -f mysql-empresa y docker volume rm mysql-empresa-datos) y vuelve a empezar. La primera vez tarda un poco: sigue el proceso con docker logs -f mysql-empresa hasta ver ready for connections.

💡 Consejo: Si prefieres docker compose, el compose.yaml equivalente solo tiene que declarar la imagen mysql:8.4, el puerto 3306:3306, las cuatro variables MYSQL_* y los dos volúmenes (mysql-empresa-datos:/var/lib/mysql y ./recursos:/docker-entrypoint-initdb.d:ro).

2.2 Cargar (o recargar) los scripts a mano

Cuando quieras dejar EMPRESA como nueva (después de tus pruebas), ejecuta los tres scripts. En Bash (Linux, macOS, Git Bash) o en el CMD de Windows, la redirección < envía el fichero al cliente mysql que hay dentro del contenedor:

docker exec -i mysql-empresa mysql -ucurso -pcurso empresa < recursos/empresa_mysql_borrar.sql
docker exec -i mysql-empresa mysql -ucurso -pcurso empresa < recursos/empresa_mysql_crear.sql
docker exec -i mysql-empresa mysql -ucurso -pcurso empresa < recursos/empresa_mysql_datos.sql

PowerShell no admite <, y canalizar con Get-Content puede estropear las tildes en Windows PowerShell 5.1. Lo más fiable es copiar los scripts al contenedor y ejecutarlos con source:

docker cp recursos mysql-empresa:/tmp/recursos
docker exec mysql-empresa mysql -ucurso -pcurso empresa -e "source /tmp/recursos/empresa_mysql_borrar.sql; source /tmp/recursos/empresa_mysql_crear.sql; source /tmp/recursos/empresa_mysql_datos.sql"

2.3 Comprobar que todo está en su sitio

docker exec mysql-empresa mysql --default-character-set=utf8mb4 -ucurso -pcurso empresa -e "SELECT COUNT(*) AS empleados FROM empleados; SELECT nombre, localidad FROM departamentos;"
mysql: [Warning] Using a password on the command line interface can be insecure.
empleados
24
nombre	localidad
DIRECCIÓN	Madrid
VENTAS	Madrid
INFORMÁTICA	Valencia
CONTABILIDAD	Sevilla
MARKETING	Barcelona
LOGÍSTICA	Zaragoza
INVESTIGACIÓN	Bilbao

💡 Consejo: El aviso Using a password on the command line interface can be insecure es normal: MySQL te recuerda que la contraseña escrita en el comando queda en el historial. Para trabajar de forma interactiva usa docker exec -it mysql-empresa mysql --default-character-set=utf8mb4 -ucurso -p empresa y escribe la contraseña cuando la pida.

⚠️ Importante: El cliente mysql de la imagen Docker usa por defecto el juego de caracteres latin1, así que sin --default-character-set=utf8mb4 verás DIRECCI?N o símbolos raros en lugar de las tildes. Los datos están bien guardados (los scripts empiezan con SET NAMES utf8mb4); es solo un problema de cómo se muestran. Desde Java no ocurre: Connector/J usa UTF-8.


3. El proyecto Maven y el driver MySQL Connector/J

Crea un proyecto Maven normal (módulo 01, groupId es.curso, artifactId jdbc-empresa, maven.compiler.release 25) y añade al pom.xml la dependencia del driver y el plugin para ejecutar clases:

<dependencies>
    <!-- Driver JDBC de MySQL (tipo 4, 100 % Java) -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <version>26.7.0</version>
    </dependency>
</dependencies>

<build>
    <plugins>
        <!-- Permite ejecutar una clase con: mvn compile exec:java -Dexec.mainClass=... -->
        <plugin>
            <groupId>org.codehaus.mojo</groupId>
            <artifactId>exec-maven-plugin</artifactId>
            <version>3.6.4</version>
        </plugin>
    </plugins>
</build>

⚠️ Importante: Fíjate en las coordenadas: com.mysql:mysql-connector-j. Las antiguas mysql:mysql-connector-java están abandonadas desde la versión 8.0.31 y solo redirigen a las nuevas. Además, MySQL cambió en 2026 la numeración de sus conectores: tras la 9.7 llegó la 26.7.0 (GA del 29 de julio de 2026), que es la que usamos. Spring Boot 4.1 todavía gestiona la 9.7.0; cualquiera de las dos funciona con MySQL 8.4.

La estructura del proyecto del módulo es esta:

jdbc-empresa/
├── pom.xml
└── src/main/
    ├── java/es/curso/jdbc/     ← Conexiones, un programa por apartado, records y ejercicios/
    └── resources/db.properties ← URL, usuario y contraseña

Cada ejemplo es una clase con main. Puedes ejecutarla desde el IDE (botón ▶) o con Maven:

mvn -q compile exec:java -Dexec.mainClass=es.curso.jdbc.PrimeraConexion

📘 Concepto: En los libros antiguos verás Class.forName("com.mysql.cj.jdbc.Driver"); antes de conectar. Desde JDBC 4.0 (Java 6) no hace falta: el .jar del driver incluye el fichero META-INF/services/java.sql.Driver y DriverManager lo descubre solo con el mecanismo ServiceLoader. Basta con que el driver esté en el classpath.


4. La conexión

4.1 La URL de conexión

Partes de la URL jdbc:mysql://localhost:3306/empresa con propiedades

Figura 3. Anatomía de una URL JDBC de MySQL.

La URL le dice a DriverManager qué driver usar (jdbc:mysql:), dónde está el servidor y a qué base de datos conectar. Detrás de ? puedes añadir propiedades del driver. Las más útiles de Connector/J:

Propiedad Para qué sirve Ejemplo
user, password Credenciales (mejor pasarlas como argumentos de getConnection) user=curso
sslMode Uso de TLS: DISABLED, PREFERRED (por defecto), REQUIRED, VERIFY_CA, VERIFY_IDENTITY sslMode=REQUIRED
allowPublicKeyRetrieval Permite pedir la clave pública RSA al servidor cuando no hay TLS (autenticación caching_sha2_password) allowPublicKeyRetrieval=true
connectTimeout Milisegundos máximos para establecer la conexión connectTimeout=5000
socketTimeout Milisegundos máximos esperando respuesta en una operación socketTimeout=30000
connectionTimeZone Zona horaria con la que interpretar fechas y horas (serverTimezone es su alias antiguo) connectionTimeZone=Europe/Madrid
rewriteBatchedStatements Reescribe los lotes de INSERT en una sola sentencia (módulo 13) rewriteBatchedStatements=true
useServerPrepStmts Prepara las sentencias en el servidor en lugar de en el cliente useServerPrepStmts=true

4.2 La primera conexión

package es.curso.jdbc;

import java.sql.*;

public class PrimeraConexion {

    // Datos de conexión (más adelante los sacaremos a un fichero de propiedades)
    private static final String URL = "jdbc:mysql://localhost:3306/empresa";
    private static final String USUARIO = "curso";
    private static final String CLAVE = "curso";

    public static void main(String[] args) {
        // try-with-resources: la conexión se cierra sola al salir del bloque
        try (Connection con = DriverManager.getConnection(URL, USUARIO, CLAVE)) {
            DatabaseMetaData meta = con.getMetaData();
            System.out.println("¡Conectado!");
            System.out.println("Base de datos : " + meta.getDatabaseProductName()
                    + " " + meta.getDatabaseProductVersion());
            System.out.println("Driver        : " + meta.getDriverName()
                    + " " + meta.getDriverVersion());
            System.out.println("Versión JDBC  : " + meta.getJDBCMajorVersion()
                    + "." + meta.getJDBCMinorVersion());
            System.out.println("Esquema actual: " + con.getCatalog());
        } catch (SQLException e) {
            System.err.println("No se pudo conectar: " + e.getMessage());
        }
    }
}

Salida (la versión exacta de MySQL depende de la imagen que hayas descargado):

¡Conectado!
Base de datos : MySQL 8.4.11
Driver        : MySQL Connector/J mysql-connector-j-26.7.0 (Revision: 559f62df01d4f618440da174ff45ba87f7b3b2a8)
Versión JDBC  : 4.2
Esquema actual: empresa

4.3 Siempre con try-with-resources

Una Connection es un recurso caro: un socket abierto, memoria en el servidor y, normalmente, un hueco de los limitados que admite MySQL (max_connections, 151 por defecto). Lo mismo pasa con Statement y ResultSet. Todos implementan AutoCloseable, así que la forma correcta de usarlos es try-with-resources:

try (Connection con = DriverManager.getConnection(url, usuario, clave);
     Statement st = con.createStatement();
     ResultSet rs = st.executeQuery("SELECT ...")) {
    // usar rs
}   // se cierran en orden inverso: rs, st y con, aunque haya una excepción

⚠️ Importante: Si olvidas cerrar las conexiones, tu programa funcionará… hasta que deje de hacerlo. En una aplicación que atiende peticiones, cada conexión olvidada se acumula hasta agotar las del servidor y aparece el error Too many connections. Es uno de los fallos más típicos en producción. En el módulo 13 veremos que con un pool de conexiones close() no cierra la conexión física, sino que la devuelve al pool: por eso hay que llamarlo igualmente.

4.4 Configuración fuera del código

Escribir la URL y la contraseña dentro del código tiene tres problemas: hay que recompilar para cambiar de servidor, la contraseña acaba en el repositorio Git y cada clase repite lo mismo. La solución es un fichero de propiedades en src/main/resources y una pequeña clase que fabrique conexiones:

# src/main/resources/db.properties
# Configuración de la conexión a la base de datos EMPRESA
db.url=jdbc:mysql://localhost:3306/empresa
db.usuario=curso
db.clave=curso
package es.curso.jdbc;

import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.sql.*;
import java.util.Properties;

/**
 * Fábrica de conexiones que lee la configuración de db.properties (en el classpath).
 * Cada propiedad puede sobrescribirse con una variable de entorno (DB_URL, DB_USUARIO, DB_CLAVE).
 */
public final class Conexiones {

    private static final Properties CONFIG = cargarConfiguracion();

    private Conexiones() { }   // clase de utilidad: no se instancia

    private static Properties cargarConfiguracion() {
        Properties props = new Properties();
        try (InputStream in = Conexiones.class.getResourceAsStream("/db.properties")) {
            if (in == null) {
                throw new IllegalStateException("No se encuentra db.properties en el classpath");
            }
            props.load(in);
        } catch (IOException e) {
            throw new UncheckedIOException("Error leyendo db.properties", e);
        }
        return props;
    }

    private static String valor(String clave, String variableEntorno) {
        String deEntorno = System.getenv(variableEntorno);
        return (deEntorno != null) ? deEntorno : CONFIG.getProperty(clave);
    }

    public static Connection obtener() throws SQLException {
        return DriverManager.getConnection(
                valor("db.url", "DB_URL"),
                valor("db.usuario", "DB_USUARIO"),
                valor("db.clave", "DB_CLAVE"));
    }
}

Con esto, el resto de programas del módulo solo escriben Conexiones.obtener(). Y si en el servidor de producción defines la variable de entorno DB_URL, se usa esa sin tocar el .jar: es la misma idea que verás en Spring Boot con application.properties y las variables de entorno.

💡 Consejo: Para proyectos reales, sube a Git un db.properties.ejemplo sin contraseñas y añade el db.properties de verdad al .gitignore. Las credenciales de producción nunca deben estar en el repositorio.

4.5 Errores típicos al conectar

Mensaje Causa habitual Solución
Communications link failure El servidor no está arrancado, el puerto es otro o un cortafuegos lo impide docker ps para ver si mysql-empresa está en marcha; revisa host y puerto
Access denied for user 'curso'@'...' Usuario o contraseña incorrectos Revisa las credenciales; recuerda que MySQL distingue el host de origen
Unknown database 'empresa' La base de datos no existe Crea el contenedor con MYSQL_DATABASE=empresa o ejecuta CREATE DATABASE
Public Key Retrieval is not allowed Conexión sin TLS con un usuario caching_sha2_password que aún no está en la caché del servidor Usa TLS (por defecto) o añade allowPublicKeyRetrieval=true solo en desarrollo

5. Statement: ejecutar SQL

Una vez tienes la conexión, le pides un Statement y ejecutas SQL con uno de estos tres métodos:

Método Para qué sentencias Devuelve
executeQuery(sql) SELECT Un ResultSet con las filas
executeUpdate(sql) INSERT, UPDATE, DELETE y DDL (CREATE, ALTER, DROP) int: número de filas afectadas (0 en el DDL)
execute(sql) Cualquiera (útil cuando no sabes de antemano qué es) boolean: true si hay ResultSet (lo obtienes con getResultSet()), false si hay un contador (getUpdateCount())

5.1 Consultar: listar los departamentos

package es.curso.jdbc;

import java.math.BigDecimal;
import java.sql.*;

public class ListarDepartamentos {

    public static void main(String[] args) {
        String sql = "SELECT dept_id, nombre, localidad, presupuesto FROM departamentos ORDER BY dept_id";

        try (Connection con = Conexiones.obtener();
             Statement st = con.createStatement();
             ResultSet rs = st.executeQuery(sql)) {

            System.out.printf("%-4s %-15s %-10s %12s%n", "ID", "NOMBRE", "LOCALIDAD", "PRESUPUESTO");
            while (rs.next()) {                                  // avanza a la siguiente fila
                int id = rs.getInt("dept_id");                    // por nombre de columna
                String nombre = rs.getString("nombre");
                String localidad = rs.getString(3);               // por índice (empieza en 1)
                BigDecimal presupuesto = rs.getBigDecimal("presupuesto");
                System.out.printf("%-4d %-15s %-10s %12s%n", id, nombre, localidad,
                        presupuesto == null ? "(sin datos)" : presupuesto);
            }
        } catch (SQLException e) {
            System.err.println("Error de base de datos: " + e.getMessage());
        }
    }
}
ID   NOMBRE          LOCALIDAD   PRESUPUESTO
10   DIRECCIÓN       Madrid        500000.00
20   VENTAS          Madrid        350000.00
30   INFORMÁTICA     Valencia      420000.00
40   CONTABILIDAD    Sevilla       150000.00
50   MARKETING       Barcelona     200000.00
60   LOGÍSTICA       Zaragoza      180000.00
70   INVESTIGACIÓN   Bilbao      (sin datos)

5.2 Modificar: executeUpdate y execute

package es.curso.jdbc;

import java.sql.*;

public class SentenciasModificacion {

    public static void main(String[] args) throws SQLException {
        try (Connection con = Conexiones.obtener();
             Statement st = con.createStatement()) {

            // executeUpdate: INSERT, UPDATE, DELETE y DDL. Devuelve el número de filas afectadas
            int filas = st.executeUpdate(
                    "UPDATE productos SET precio = precio * 1.10 WHERE categoria_id = 6");
            System.out.println("Monitores encarecidos un 10 %: " + filas + " filas");

            filas = st.executeUpdate(
                    "UPDATE productos SET precio = precio / 1.10 WHERE categoria_id = 6");
            System.out.println("Precios restaurados: " + filas + " filas");

            // execute: sirve para cualquier sentencia; devuelve true si produce un ResultSet
            boolean hayResultSet = st.execute("SELECT COUNT(*) FROM empleados");
            if (hayResultSet) {
                try (ResultSet rs = st.getResultSet()) {
                    rs.next();
                    System.out.println("execute(SELECT) -> true, empleados: " + rs.getInt(1));
                }
            }
            hayResultSet = st.execute("UPDATE empleados SET comision = 0 WHERE comision IS NULL AND oficio = 'VENDEDOR'");
            System.out.println("execute(UPDATE) -> " + hayResultSet + ", filas: " + st.getUpdateCount());
        }
    }
}
Monitores encarecidos un 10 %: 2 filas
Precios restaurados: 2 filas
execute(SELECT) -> true, empleados: 24
execute(UPDATE) -> false, filas: 0

El último UPDATE no afecta a ninguna fila (todos los vendedores tienen comisión) y no es un error: executeUpdate y getUpdateCount devuelven 0. Comprueba siempre ese número cuando esperas modificar algo concreto.

⚠️ Importante: Por defecto JDBC trabaja en modo autocommit: cada sentencia se confirma en cuanto termina. Por eso el UPDATE de los precios es definitivo nada más ejecutarse y hemos tenido que deshacerlo con otro UPDATE. En el módulo 13 aprenderás a agrupar sentencias en transacciones para confirmarlas o deshacerlas juntas.

Un mismo Statement puede reutilizarse para varias sentencias, pero ejecutar una nueva cierra el ResultSet anterior del mismo Statement. Si necesitas dos ResultSet abiertos a la vez, usa dos Statement.


6. ResultSet: leer los resultados

6.1 Un cursor que avanza

El ResultSet como cursor: empieza antes de la primera fila y cada next() avanza una

Figura 4. El ResultSet es un cursor: empieza antes de la primera fila y next() lo mueve hacia abajo.

Cuando ejecutas una consulta, el ResultSet no está colocado en ninguna fila: el cursor está antes de la primera. Cada llamada a next() avanza una fila y devuelve true si ha llegado a una fila válida o false si ya no quedan. Por eso el patrón universal es:

while (rs.next()) {
    // leer columnas de la fila actual
}

⚠️ Importante: Leer una columna sin haber llamado a next() produce SQLException: Before start of result set. Y por defecto el ResultSet es forward-only: solo avanza; no puedes volver atrás (salvo que lo pidas explícitamente, sección 13).

6.2 Getters: por nombre o por índice

Cada tipo Java tiene su getter: getInt, getLong, getString, getBigDecimal, getBoolean, getDate, getObject… Todos aceptan el nombre de la columna (o el alias del AS) o su índice, que empieza en 1.

Por nombre (rs.getString("nombre")) el código es legible y no depende del orden del SELECT, aunque un error tipográfico solo se descubre al ejecutar. Por índice (rs.getString(2)) es algo más rápido y útil con columnas calculadas sin alias, pero si cambias el orden del SELECT leerás la columna equivocada. Usa nombres o alias en la aplicación y reserva los índices para utilidades genéricas (ejercicio 10).

6.3 Correspondencia de tipos SQL y Java

Tipo en MySQL Tipo JDBC (java.sql.Types) Getter recomendado Tipo Java
INT INTEGER getInt / getObject(..., Integer.class) int / Integer
DECIMAL(p,s) DECIMAL getBigDecimal BigDecimal
VARCHAR, CHAR, TEXT VARCHAR, CHAR, LONGVARCHAR getString String
BOOLEAN / TINYINT(1) BIT / BOOLEAN getBoolean boolean
DATE DATE getObject(..., LocalDate.class) LocalDate
DATETIME TIMESTAMP getObject(..., LocalDateTime.class) LocalDateTime
TIMESTAMP TIMESTAMP getObject(..., LocalDateTime.class) o OffsetDateTime LocalDateTime / OffsetDateTime

⚠️ Importante: Para importes de dinero (salario, precio, presupuesto) usa siempre BigDecimal. Con double aparecen errores de redondeo (0.1 + 0.2 = 0.30000000000000004) que en una factura son inaceptables.

6.4 El problema de los NULL

En SQL una columna puede no tener valor (NULL). En Java los tipos primitivos (int, double, boolean) no pueden ser null, así que getInt devuelve 0 cuando la columna es NULL. ¿Cómo distingues un 0 de verdad de un NULL? Con wasNull(), que indica si la última columna leída era NULL. La alternativa moderna es pedir el tipo objeto con getObject(columna, Clase), que devuelve null.

package es.curso.jdbc;

import java.math.BigDecimal;
import java.sql.*;
import java.time.LocalDate;
import java.time.Period;

public class TiposYNulos {

    public static void main(String[] args) throws SQLException {
        String sql = """
                SELECT emp_id, nombre, telefono, fecha_alta, salario, comision
                FROM empleados
                WHERE emp_id IN (110, 113, 114, 123)
                ORDER BY emp_id""";
        LocalDate hoy = LocalDate.of(2026, 10, 1);   // fecha fija para que la salida sea reproducible

        try (Connection con = Conexiones.obtener();
             Statement st = con.createStatement();
             ResultSet rs = st.executeQuery(sql)) {

            while (rs.next()) {
                String nombre = rs.getString("nombre");

                // DATE -> LocalDate (JDBC 4.2): sin pasar por java.sql.Date
                LocalDate alta = rs.getObject("fecha_alta", LocalDate.class);
                Period antiguedad = Period.between(alta, hoy);

                // DECIMAL -> BigDecimal: nunca uses double para dinero
                BigDecimal salario = rs.getBigDecimal("salario");

                // getInt() devuelve 0 si la columna es NULL: hay que preguntar a wasNull()
                int comision = rs.getInt("comision");
                boolean comisionNula = rs.wasNull();

                // Con getObject(..., Clase) el NULL llega como null
                BigDecimal comisionBD = rs.getObject("comision", BigDecimal.class);

                String telefono = rs.getString("telefono");   // getString devuelve null si es NULL

                System.out.printf("%-7s alta=%s (%d años) salario=%s getInt=%d wasNull=%b getObject=%s tel=%s%n",
                        nombre, alta, antiguedad.getYears(), salario, comision, comisionNula,
                        comisionBD, telefono);
            }
        }
    }
}
Carmen  alta=2016-04-12 (10 años) salario=2200.00 getInt=500 wasNull=false getObject=500.00 tel=600100110
Sergio  alta=2021-09-01 (5 años) salario=1900.00 getInt=0 wasNull=false getObject=0.00 tel=600100113
Lucía   alta=2020-02-10 (6 años) salario=1700.00 getInt=0 wasNull=true getObject=null tel=600100114
Sara    alta=2022-06-01 (4 años) salario=2400.00 getInt=0 wasNull=true getObject=null tel=null

Mira a Sergio y a Lucía: los dos dan getInt=0, pero Sergio tiene una comisión de 0 € y Lucía no tiene comisión (NULL). Solo wasNull() o getObject permiten distinguirlos.

💡 Consejo: En tus records y clases de modelo, usa tipos objeto (Integer, BigDecimal) para las columnas que admiten NULL (como comision, jefe_id o dept_id en empleados) y léelas con getObject(..., Clase). Así el NULL de la base de datos se convierte en null de Java de forma natural.

6.5 Fechas con java.time

Desde JDBC 4.2 puedes leer y escribir directamente los tipos de java.time (módulo 05) con getObject y setObject. Olvídate de java.sql.Date, java.sql.Timestamp y de java.util.Date:

rs.getObject("fecha_alta", LocalDate.class) para leer un DATE, getObject(col, LocalDateTime.class) para un DATETIME y ps.setObject(i, LocalDate.of(2026, 1, 1)) para pasar un parámetro. Si encuentras código antiguo con java.sql.Date, convierte con fecha.toLocalDate() y java.sql.Date.valueOf(localDate).

⚠️ Importante: Con columnas DATETIME y TIMESTAMP entra en juego la zona horaria. LocalDate/LocalDateTime no tienen zona y se guardan “tal cual”; si trabajas con instantes (Instant, OffsetDateTime) configura connectionTimeZone en la URL para que el driver sepa cómo convertir. Para las columnas DATE de EMPRESA no hay ningún problema.


7. Inyección SQL y PreparedStatement

7.1 La inyección SQL, en directo

Imagina un buscador de empleados por correo electrónico en el que el usuario escribe el email. La tentación es construir el SQL concatenando:

package es.curso.jdbc;

import java.sql.*;

public class InyeccionSql {

    // ¡MAL! La entrada del usuario se pega dentro del SQL
    static void buscarInseguro(Connection con, String email) throws SQLException {
        String sql = "SELECT emp_id, nombre, salario FROM empleados WHERE email = '" + email + "'";
        System.out.println("SQL enviado: " + sql);
        try (Statement st = con.createStatement();
             ResultSet rs = st.executeQuery(sql)) {
            imprimir(rs);
        }
    }

    // BIEN: la sentencia se envía con un hueco (?) y el dato viaja aparte
    static void buscarSeguro(Connection con, String email) throws SQLException {
        String sql = "SELECT emp_id, nombre, salario FROM empleados WHERE email = ?";
        try (PreparedStatement ps = con.prepareStatement(sql)) {
            ps.setString(1, email);
            System.out.println("SQL enviado: " + sql + "   parámetro 1 = [" + email + "]");
            try (ResultSet rs = ps.executeQuery()) {
                imprimir(rs);
            }
        }
    }

    static void imprimir(ResultSet rs) throws SQLException {
        int n = 0;
        while (rs.next()) {
            n++;
            if (n <= 3) {
                System.out.println("   " + rs.getInt(1) + " " + rs.getString(2) + " " + rs.getBigDecimal(3));
            }
        }
        System.out.println("   -> " + n + " fila(s)" + (n > 3 ? " (se muestran 3)" : ""));
    }

    public static void main(String[] args) throws SQLException {
        String normal = "ana.garcia@empresa.es";
        String ataque = "x' OR '1'='1";

        try (Connection con = Conexiones.obtener()) {
            System.out.println("== Statement con concatenación ==");
            buscarInseguro(con, normal);
            buscarInseguro(con, ataque);

            System.out.println("== PreparedStatement ==");
            buscarSeguro(con, normal);
            buscarSeguro(con, ataque);
        }
    }
}
== Statement con concatenación ==
SQL enviado: SELECT emp_id, nombre, salario FROM empleados WHERE email = 'ana.garcia@empresa.es'
   100 Ana 6500.00
   -> 1 fila(s)
SQL enviado: SELECT emp_id, nombre, salario FROM empleados WHERE email = 'x' OR '1'='1'
   100 Ana 6500.00
   101 Luis 4800.00
   102 Marta 5000.00
   -> 24 fila(s) (se muestran 3)
== PreparedStatement ==
SQL enviado: SELECT emp_id, nombre, salario FROM empleados WHERE email = ?   parámetro 1 = [ana.garcia@empresa.es]
   100 Ana 6500.00
   -> 1 fila(s)
SQL enviado: SELECT emp_id, nombre, salario FROM empleados WHERE email = ?   parámetro 1 = [x' OR '1'='1]
   -> 0 fila(s)

Pasos de una inyección SQL: la entrada cierra la cadena y añade OR '1'='1'

Figura 5. Cómo funciona una inyección SQL.

Con un texto cuidadosamente elegido, el “dato” ha cerrado la comilla y ha añadido código SQL a la consulta: ahora la condición es siempre verdadera y se filtran los salarios de toda la plantilla. Es la vulnerabilidad web más conocida y sigue apareciendo cada año en la lista OWASP Top 10 (categoría Injection). Con otras entradas un atacante podría saltarse un login (' OR 1=1 -- ), leer otras tablas con UNION SELECT o modificar datos.

📘 Concepto: La inyección SQL consiste en introducir fragmentos de SQL a través de datos que el programa concatena en una sentencia. El servidor no puede distinguir qué parte escribió el programador y qué parte el usuario, así que lo ejecuta todo.

💡 Consejo: Connector/J no permite por defecto varias sentencias separadas por ; en una sola llamada (la propiedad allowMultiQueries vale false), así que el clásico '; DROP TABLE empleados; -- fallaría. Pero no te confíes: el ejemplo anterior demuestra que una sola sentencia manipulada basta para filtrar datos.

7.2 Cómo funciona PreparedStatement

Pasos de un PreparedStatement: se prepara el SQL con huecos y los valores viajan aparte

Figura 6. Cómo funciona un PreparedStatement.

Con PreparedStatement escribes el SQL con marcadores ? y das los valores aparte con setXxx(posición, valor). La estructura de la consulta queda fijada antes de conocer los datos, y los datos se tratan siempre como valores, nunca como código. Por eso la entrada maliciosa se busca literalmente como email y devuelve 0 filas.

Además de la seguridad, PreparedStatement aporta:

  • Tipos correctos: setBigDecimal, setObject(LocalDate)… sin pelearte con comillas, formatos de fecha o el separador decimal.
  • Rendimiento: una sentencia preparada se puede reutilizar con distintos valores; si activas useServerPrepStmts=true (y su caché, módulo 13), el servidor la analiza una sola vez.
  • Legibilidad: el SQL se lee entero, sin trocitos de cadenas concatenadas.

⚠️ Importante: Regla de oro: nunca concatenes en un SQL un valor que venga de fuera (teclado, formulario web, fichero, petición HTTP). Usa siempre PreparedStatement con parámetros. Statement solo es aceptable para SQL fijo, escrito íntegramente por ti.

7.3 Los parámetros

Los parámetros se numeran desde 1, en el orden en que aparecen los ?:

Método Tipo SQL Ejemplo
setInt(i, int) / setLong INT, BIGINT ps.setInt(1, 30)
setString(i, String) VARCHAR, CHAR ps.setString(2, "VENTAS")
setBigDecimal(i, BigDecimal) DECIMAL ps.setBigDecimal(3, new BigDecimal("2000"))
setBoolean(i, boolean) BOOLEAN ps.setBoolean(1, true)
setObject(i, LocalDate) DATE (y demás tipos java.time) ps.setObject(4, LocalDate.of(2026, 1, 1))
setNull(i, Types.X) Cualquiera ps.setNull(5, Types.DECIMAL)
setObject(i, valor, Types.X) Cualquiera, admite null ps.setObject(6, jefeId, Types.INTEGER)
clearParameters() — Borra los valores asignados
package es.curso.jdbc;

import java.math.BigDecimal;
import java.sql.*;
import java.time.LocalDate;

public class ConsultasParametrizadas {

    public static void main(String[] args) throws SQLException {
        String sql = """
                SELECT nombre, apellidos, salario, fecha_alta
                FROM empleados
                WHERE dept_id = ? AND salario >= ? AND fecha_alta >= ?
                ORDER BY salario DESC""";

        try (Connection con = Conexiones.obtener();
             PreparedStatement ps = con.prepareStatement(sql)) {

            // Una misma sentencia preparada se reutiliza con distintos valores
            int[] departamentos = {20, 30};
            for (int dept : departamentos) {
                ps.setInt(1, dept);                                   // 1.er ?
                ps.setBigDecimal(2, new BigDecimal("2000"));          // 2.º ?
                ps.setObject(3, LocalDate.of(2016, 1, 1));            // 3.er ? (LocalDate -> DATE)
                System.out.println("Departamento " + dept + ":");
                try (ResultSet rs = ps.executeQuery()) {
                    while (rs.next()) {
                        System.out.printf("  %-8s %-18s %8s %s%n", rs.getString("nombre"),
                                rs.getString("apellidos"), rs.getBigDecimal("salario"),
                                rs.getObject("fecha_alta", LocalDate.class));
                    }
                }
            }
        }
    }
}
Departamento 20:
  Elena    Navarro Castro      2300.00 2019-01-15
  Carmen   Ruiz Ortega         2200.00 2016-04-12
  David    Torres Romero       2100.00 2017-07-01
Departamento 30:
  Raquel   Molina Prieto       2800.00 2018-10-01
  Iván     Castro Méndez       2600.00 2020-03-16
  Hugo     Rubio Marín         2500.00 2019-11-20
  Sara     Ortiz Delgado       2400.00 2022-06-01

Con LIKE, los comodines van en el valor, no en el SQL: "... WHERE nombre LIKE ?" y ps.setString(1, "%" + texto + "%").

7.4 Lo que NO puede ser un parámetro

Un ? sustituye a un valor, nunca a un trozo de la estructura del SQL. No puedes parametrizar nombres de tablas o columnas, palabras clave ni la dirección de ORDER BY:

// NO funciona: "SELECT * FROM ?"  ni  "ORDER BY ? DESC"

Si de verdad necesitas que el usuario elija una columna de ordenación, usa una lista blanca: compara su elección con un conjunto cerrado de valores permitidos y solo entonces la concatenas.

private static final Map<String, String> ORDENES = Map.of(
        "nombre", "apellidos, nombre",
        "salario", "salario DESC",
        "antiguedad", "fecha_alta");

String orden = ORDENES.getOrDefault(eleccionUsuario, "emp_id");   // nunca el texto del usuario
String sql = "SELECT * FROM empleados ORDER BY " + orden;

Para una lista de longitud variable (IN (...)) genera tantos ? como elementos:

List<Integer> ids = List.of(110, 111, 112);
String huecos = String.join(", ", Collections.nCopies(ids.size(), "?"));   // "?, ?, ?"
String sql = "SELECT nombre FROM empleados WHERE emp_id IN (" + huecos + ")";
try (PreparedStatement ps = con.prepareStatement(sql)) {
    for (int i = 0; i < ids.size(); i++) {
        ps.setInt(i + 1, ids.get(i));
    }
    // ...
}

8. Insertar, actualizar y borrar

8.1 Recuperar la clave generada

Las tablas de EMPRESA usan claves AUTO_INCREMENT: al insertar no das el dept_id, lo genera MySQL. Para saber qué valor ha generado, prepara la sentencia con Statement.RETURN_GENERATED_KEYS y, tras ejecutarla, lee getGeneratedKeys(), que es un pequeño ResultSet con las claves.

package es.curso.jdbc;

import java.math.BigDecimal;
import java.sql.*;

public class CrudDepartamento {

    // INSERT y recuperación de la clave AUTO_INCREMENT generada
    static int insertarDepartamento(Connection con, String nombre, String localidad,
                                    BigDecimal presupuesto) throws SQLException {
        String sql = "INSERT INTO departamentos (nombre, localidad, presupuesto) VALUES (?, ?, ?)";
        try (PreparedStatement ps = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, nombre);
            ps.setString(2, localidad);
            if (presupuesto == null) {
                ps.setNull(3, Types.DECIMAL);           // NULL explícito
            } else {
                ps.setBigDecimal(3, presupuesto);
            }
            ps.executeUpdate();
            try (ResultSet claves = ps.getGeneratedKeys()) {
                if (claves.next()) {
                    return claves.getInt(1);
                }
                throw new SQLException("No se generó ninguna clave");
            }
        }
    }

    static int actualizarPresupuesto(Connection con, int deptId, BigDecimal nuevo) throws SQLException {
        try (PreparedStatement ps = con.prepareStatement(
                "UPDATE departamentos SET presupuesto = ? WHERE dept_id = ?")) {
            ps.setBigDecimal(1, nuevo);
            ps.setInt(2, deptId);
            return ps.executeUpdate();
        }
    }

    static int borrar(Connection con, String tabla, String columnaId, int id) throws SQLException {
        // El nombre de tabla NO puede ir como parámetro (?): solo los valores
        try (PreparedStatement ps = con.prepareStatement(
                "DELETE FROM " + tabla + " WHERE " + columnaId + " = ?")) {
            ps.setInt(1, id);
            return ps.executeUpdate();
        }
    }

    public static void main(String[] args) throws SQLException {
        try (Connection con = Conexiones.obtener()) {
            int deptId = insertarDepartamento(con, "CALIDAD", "Málaga", null);
            System.out.println("Departamento creado con id " + deptId);

            System.out.println("Presupuesto actualizado: "
                    + actualizarPresupuesto(con, deptId, new BigDecimal("95000")) + " fila(s)");
            System.out.println("Actualizar un id inexistente: "
                    + actualizarPresupuesto(con, 999, BigDecimal.TEN) + " fila(s)");

            System.out.println("Departamentos borrados: " + borrar(con, "departamentos", "dept_id", deptId));
        }
    }
}

Salida con la base de datos recién cargada:

Departamento creado con id 71
Presupuesto actualizado: 1 fila(s)
Actualizar un id inexistente: 0 fila(s)
Departamentos borrados: 1

El departamento recibe el 71 porque el mayor existente era el 70. Si lo ejecutas otra vez obtendrás el 72: MySQL no reutiliza los valores de AUTO_INCREMENT aunque borres las filas. No construyas lógica que dependa de que los identificadores sean consecutivos.

Para una columna que admite NULL y cuyo valor está en una variable que puede ser null (por ejemplo el jefe_id de un empleado nuevo), la forma más cómoda es ps.setObject(7, jefeId, Types.INTEGER): envía el número o NULL según el caso.

💡 Consejo: El método borrar concatena el nombre de la tabla porque no puede ir en un ?. Es seguro solo porque esos nombres los escribe el programador; jamás deben venir del usuario (recuerda la lista blanca del apartado 7.4).

8.2 Qué significa el número que devuelve executeUpdate

Un INSERT de una fila devuelve 1; un UPDATE o DELETE, las filas que cumplen el WHERE (con Connector/J, las encontradas aunque el valor no cambie); el DDL, 0. Usa ese número para detectar situaciones anómalas: si actualizarPresupuesto devuelve 0, el departamento no existe y quizá debas avisar al usuario o lanzar una excepción.


9. Mapear filas a objetos

Trabajar con ResultSet por todo el programa es incómodo y propenso a errores. Lo profesional es convertir cada fila en un objeto del modelo nada más leerla. Los records (Java 16) son perfectos para esto: inmutables, con constructor, equals, hashCode y toString gratis.

package es.curso.jdbc;

import java.math.BigDecimal;
import java.time.LocalDate;

/** Una fila de la tabla empleados. Las columnas que admiten NULL usan tipos objeto. */
public record Empleado(int id, String nombre, String apellidos, String email, String telefono,
                       LocalDate fechaAlta, String oficio, BigDecimal salario,
                       BigDecimal comision, Integer jefeId, Integer deptId) {

    public String nombreCompleto() {
        return nombre + " " + apellidos;
    }
}

// Departamento.java (mismo paquete e import de BigDecimal)
/** Una fila de la tabla departamentos. */
public record Departamento(int id, String nombre, String localidad, BigDecimal presupuesto) { }

El método que convierte una fila en un objeto se suele llamar mapper. Fíjate en que no llama a next(): transforma la fila en la que está el cursor, y quien lo usa decide cómo recorrer.

package es.curso.jdbc;

import java.sql.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class MapeoObjetos {

    // Convierte la fila ACTUAL del ResultSet en un objeto (no llama a next())
    static Empleado mapearEmpleado(ResultSet rs) throws SQLException {
        return new Empleado(
                rs.getInt("emp_id"),
                rs.getString("nombre"),
                rs.getString("apellidos"),
                rs.getString("email"),
                rs.getString("telefono"),
                rs.getObject("fecha_alta", LocalDate.class),
                rs.getString("oficio"),
                rs.getBigDecimal("salario"),
                rs.getBigDecimal("comision"),            // null si es NULL
                rs.getObject("jefe_id", Integer.class),  // null si es NULL
                rs.getObject("dept_id", Integer.class));
    }

    static List<Empleado> empleadosDeDepartamento(Connection con, int deptId) throws SQLException {
        List<Empleado> lista = new ArrayList<>();
        try (PreparedStatement ps = con.prepareStatement(
                "SELECT * FROM empleados WHERE dept_id = ? ORDER BY emp_id")) {
            ps.setInt(1, deptId);
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    lista.add(mapearEmpleado(rs));
                }
            }
        }
        return lista;
    }

    static Optional<Departamento> buscarDepartamento(Connection con, int id) throws SQLException {
        try (PreparedStatement ps = con.prepareStatement(
                "SELECT dept_id, nombre, localidad, presupuesto FROM departamentos WHERE dept_id = ?")) {
            ps.setInt(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(new Departamento(rs.getInt(1), rs.getString(2),
                            rs.getString(3), rs.getBigDecimal(4)));
                }
                return Optional.empty();
            }
        }
    }

    public static void main(String[] args) throws SQLException {
        try (Connection con = Conexiones.obtener()) {
            Departamento info = buscarDepartamento(con, 30).orElseThrow();
            System.out.println(info);

            List<Empleado> empleados = empleadosDeDepartamento(con, 30);
            empleados.forEach(e -> System.out.printf("  %d %-22s %-16s %s%n",
                    e.id(), e.nombreCompleto(), e.oficio(), e.salario()));

            System.out.println("Departamento 99: " + buscarDepartamento(con, 99));
        }
    }
}
Departamento[id=30, nombre=INFORMÁTICA, localidad=Valencia, presupuesto=420000.00]
  102 Marta Sánchez Gil      DIRECTOR         5000.00
  120 Pablo Moreno Herrera   ANALISTA         3500.00
  121 Raquel Molina Prieto   PROGRAMADOR      2800.00
  122 Iván Castro Méndez     PROGRAMADOR      2600.00
  123 Sara Ortiz Delgado     PROGRAMADOR      2400.00
  124 Hugo Rubio Marín       TÉCNICO SISTEMAS 2500.00
Departamento 99: Optional.empty

💡 Consejo: Devolver Optional<Departamento> en las búsquedas por clave (módulo 03) deja claro en la firma del método que el resultado puede no existir, y evita los null sorpresa. Es exactamente lo que hacen los repositorios de Spring Data con findById. En el módulo 13 el mapper se convertirá en una interfaz funcional (RowMapper) y agruparemos estas consultas en clases DAO.


10. Consultas con JOIN

Un JOIN devuelve columnas de varias tablas. Cuando dos tablas tienen columnas con el mismo nombre (aquí empleados.nombre y departamentos.nombre), dales un alias con AS y lee por el alias. El resultado no corresponde a ninguna tabla, así que creamos un record “de consulta” a medida.

package es.curso.jdbc;

import java.sql.*;
import java.util.ArrayList;
import java.util.List;

public class ConsultasJoin {

    // Record "de consulta": no corresponde a una tabla sino al resultado del JOIN
    record EmpleadoConDepartamento(int empId, String nombre, String oficio,
                                   String departamento, String localidad, String jefe) { }


    public static void main(String[] args) throws SQLException {
        String sql = """
                SELECT e.emp_id, CONCAT(e.nombre, ' ', e.apellidos) AS nombre, e.oficio,
                       d.nombre AS departamento, d.localidad,
                       j.nombre AS jefe
                FROM empleados e
                LEFT JOIN departamentos d ON d.dept_id = e.dept_id
                LEFT JOIN empleados j     ON j.emp_id  = e.jefe_id
                WHERE e.oficio IN (?, ?)
                ORDER BY e.emp_id""";

        List<EmpleadoConDepartamento> lista = new ArrayList<>();
        try (Connection con = Conexiones.obtener()) {
            try (PreparedStatement ps = con.prepareStatement(sql)) {
                ps.setString(1, "PROGRAMADOR");
                ps.setString(2, "BECARIO");
                try (ResultSet rs = ps.executeQuery()) {
                    while (rs.next()) {
                        lista.add(new EmpleadoConDepartamento(
                                rs.getInt("emp_id"),
                                rs.getString("nombre"),        // etiqueta del alias AS
                                rs.getString("oficio"),
                                rs.getString("departamento"),
                                rs.getString("localidad"),
                                rs.getString("jefe")));
                    }
                }
            }
            for (EmpleadoConDepartamento e : lista) {
                System.out.printf("%d %-20s %-12s %-12s %-9s jefe: %s%n", e.empId(), e.nombre(),
                        e.oficio(),
                        e.departamento() == null ? "(ninguno)" : e.departamento(),
                        e.localidad() == null ? "-" : e.localidad(), e.jefe());
            }

            // JOIN + GROUP BY: los departamentos sin empleados también aparecen
            String resumen = """
                    SELECT d.nombre, COUNT(e.emp_id) AS num, AVG(e.salario) AS media
                    FROM departamentos d
                    LEFT JOIN empleados e ON e.dept_id = d.dept_id
                    GROUP BY d.dept_id, d.nombre
                    ORDER BY num DESC, d.nombre""";
            try (PreparedStatement ps = con.prepareStatement(resumen);
                 ResultSet rs = ps.executeQuery()) {
                System.out.println("--- Resumen por departamento ---");
                while (rs.next()) {
                    java.math.BigDecimal media = rs.getBigDecimal("media");      // NULL si no hay empleados
                    System.out.printf("%-14s %2d empleados, media %s%n", rs.getString("nombre"), rs.getInt("num"),
                            media == null ? "-" : media.setScale(2, java.math.RoundingMode.HALF_UP));
                }
            }
        }
    }
}
121 Raquel Molina Prieto PROGRAMADOR  INFORMÁTICA  Valencia  jefe: Pablo
122 Iván Castro Méndez   PROGRAMADOR  INFORMÁTICA  Valencia  jefe: Pablo
123 Sara Ortiz Delgado   PROGRAMADOR  INFORMÁTICA  Valencia  jefe: Pablo
160 Tomás Reyes Fuentes  BECARIO      (ninguno)    -         jefe: Marta
--- Resumen por departamento ---
INFORMÁTICA     6 empleados, media 3133.33
VENTAS          6 empleados, media 2500.00
LOGÍSTICA       4 empleados, media 2237.50
CONTABILIDAD    3 empleados, media 2816.67
MARKETING       3 empleados, media 3100.00
DIRECCIÓN       1 empleados, media 6500.00
INVESTIGACIÓN   0 empleados, media -

Observa dos detalles que los datos de EMPRESA están pensados para mostrar:

  • Tomás, el becario, no tiene departamento: con LEFT JOIN aparece igualmente, con departamento y localidad a NULL (que el programa muestra como “(ninguno)” y “-“).
  • INVESTIGACIÓN no tiene empleados: COUNT(e.emp_id) da 0 y AVG da NULL, por eso comprobamos media == null.

⚠️ Importante: Si en un JOIN dos columnas se llaman igual y no les pones alias, rs.getString("nombre") devolverá la primera que encuentre, y es fácil leer el nombre del empleado cuando querías el del departamento. Pon siempre alias en las columnas repetidas.


11. Procedimientos almacenados con CallableStatement

Un procedimiento almacenado es código SQL guardado en el servidor. Desde Java se llama con un CallableStatement y la sintaxis de escape {call nombre(?, ?, ...)}. Los parámetros de entrada se asignan con setXxx; los de salida se registran con registerOutParameter y se leen después de ejecutar.

package es.curso.jdbc;

import java.sql.*;

public class Procedimientos {

    public static void main(String[] args) throws SQLException {
        try (Connection con = Conexiones.obtener()) {
            // 1. Creamos el procedimiento (normalmente lo haría el DBA con un script SQL)
            try (Statement st = con.createStatement()) {
                st.execute("""
                        CREATE PROCEDURE resumen_departamento(
                            IN p_dept INT, OUT p_num INT, OUT p_total DECIMAL(12,2))
                        BEGIN
                            SELECT COUNT(*), COALESCE(SUM(salario), 0) INTO p_num, p_total
                              FROM empleados WHERE dept_id = p_dept;
                        END""");
            }

            // 2. Lo llamamos con la sintaxis de escape {call ...}
            try (CallableStatement cs = con.prepareCall("{call resumen_departamento(?, ?, ?)}")) {
                cs.setInt(1, 40);                              // parámetro IN
                cs.registerOutParameter(2, Types.INTEGER);     // parámetros OUT
                cs.registerOutParameter(3, Types.DECIMAL);
                cs.execute();
                System.out.println("Departamento 40: " + cs.getInt(2) + " empleados, masa salarial "
                        + cs.getBigDecimal(3) + " €");
            }

            try (Statement st = con.createStatement()) {
                st.execute("DROP PROCEDURE resumen_departamento");   // dejamos la BD como estaba
            }
        }
    }
}
Departamento 40: 3 empleados, masa salarial 8450.00 €

💡 Consejo: Desde JDBC no hace falta el DELIMITER // del cliente mysql: cada execute envía una sentencia completa. Si el procedimiento devuelve filas con un SELECT, execute() devuelve true y las lees con cs.getResultSet(). Los procedimientos almacenados son habituales en bases de datos heredadas (sobre todo Oracle), pero en aplicaciones nuevas la lógica de negocio suele vivir en Java, donde es más fácil de probar y versionar.


12. Metadatos

JDBC permite preguntar a la base de datos por su propia estructura:

  • DatabaseMetaData (de con.getMetaData()): producto, versión, tablas, columnas, claves primarias y ajenas, capacidades del driver…
  • ResultSetMetaData (de rs.getMetaData()): número de columnas de un resultado, sus nombres, etiquetas, tipos y la clase Java correspondiente.

Es lo que usan los IDE para dibujar el árbol de tablas y las herramientas que generan código a partir de la base de datos.

package es.curso.jdbc;

import java.sql.*;

public class Metadatos {

    public static void main(String[] args) throws SQLException {
        try (Connection con = Conexiones.obtener()) {
            DatabaseMetaData db = con.getMetaData();
            String esquema = con.getCatalog();     // en MySQL la "base de datos" es el catálogo

            // 1. Tablas del esquema
            System.out.println("Tablas de " + esquema + ":");
            try (ResultSet rs = db.getTables(esquema, null, "%", new String[]{"TABLE"})) {
                StringBuilder sb = new StringBuilder("  ");
                while (rs.next()) {
                    sb.append(rs.getString("TABLE_NAME")).append(' ');
                }
                System.out.println(sb.toString().trim().isEmpty() ? "  (ninguna)" : sb);
            }

            // 2. Columnas de una tabla
            System.out.println("Columnas de productos:");
            try (ResultSet rs = db.getColumns(esquema, null, "productos", "%")) {
                while (rs.next()) {
                    System.out.printf("  %-14s %-8s tamaño=%-4d nulos=%s%n",
                            rs.getString("COLUMN_NAME"), rs.getString("TYPE_NAME"),
                            rs.getInt("COLUMN_SIZE"), rs.getString("IS_NULLABLE"));
                }
            }

            // 3. Claves ajenas
            try (ResultSet rs = db.getImportedKeys(esquema, null, "empleados")) {
                System.out.println("FK de empleados:");
                while (rs.next()) {
                    System.out.println("  " + rs.getString("FKCOLUMN_NAME") + " -> "
                            + rs.getString("PKTABLE_NAME") + "." + rs.getString("PKCOLUMN_NAME")
                            + " (" + rs.getString("FK_NAME") + ")");
                }
            }

            // 4. ResultSetMetaData: describe las columnas de CUALQUIER consulta
            try (Statement st = con.createStatement();
                 ResultSet rs = st.executeQuery("""
                         SELECT p.nombre, c.nombre AS categoria, p.precio, p.stock, p.fecha_alta
                         FROM productos p JOIN categorias c ON c.categoria_id = p.categoria_id
                         WHERE p.precio > 1000""")) {
                ResultSetMetaData md = rs.getMetaData();
                System.out.println("La consulta devuelve " + md.getColumnCount() + " columnas:");
                for (int i = 1; i <= md.getColumnCount(); i++) {
                    System.out.printf("  %d %-10s tabla=%-10s tipo=%-8s clase Java=%s%n", i,
                            md.getColumnLabel(i), md.getTableName(i), md.getColumnTypeName(i),
                            md.getColumnClassName(i));
                }
            }
        }
    }
}
Tablas de empresa:
  asignaciones categorias clientes departamentos empleados lineas_pedido pedidos productos proyectos 
Columnas de productos:
  producto_id    INT      tamaño=10   nulos=NO
  nombre         VARCHAR  tamaño=60   nulos=NO
  descripcion    VARCHAR  tamaño=200  nulos=YES
  categoria_id   INT      tamaño=10   nulos=NO
  precio         DECIMAL  tamaño=8    nulos=NO
  stock          INT      tamaño=10   nulos=NO
  fecha_alta     DATE     tamaño=10   nulos=YES
  descatalogado  CHAR     tamaño=1    nulos=NO
FK de empleados:
  dept_id -> departamentos.dept_id (fk_emp_dept)
  jefe_id -> empleados.emp_id (fk_emp_jefe)
La consulta devuelve 5 columnas:
  1 nombre     tabla=productos  tipo=VARCHAR  clase Java=java.lang.String
  2 categoria  tabla=categorias tipo=VARCHAR  clase Java=java.lang.String
  3 precio     tabla=productos  tipo=DECIMAL  clase Java=java.math.BigDecimal
  4 stock      tabla=productos  tipo=INT      clase Java=java.lang.Integer
  5 fecha_alta tabla=productos  tipo=DATE     clase Java=java.sql.Date

getPrimaryKeys funciona igual que getImportedKeys; ojo: devuelve las columnas ordenadas por nombre y su posición real en la clave está en KEY_SEQ. Fíjate también en que getColumnClassName informa de java.sql.Date para las fechas porque es lo que devuelve getObject(i) sin indicar clase; con getObject(i, LocalDate.class) obtienes el tipo moderno.

📘 Concepto: getColumnName devuelve el nombre real de la columna en la tabla y getColumnLabel el alias del AS (o el nombre si no hay alias). Para mostrar resultados usa siempre getColumnLabel.


13. ResultSet desplazable y actualizable

Por defecto un ResultSet es TYPE_FORWARD_ONLY y CONCUR_READ_ONLY: solo avanza y no se puede modificar. Al crear la sentencia puedes pedir otras características:

Tipo Significado
TYPE_FORWARD_ONLY Solo hacia delante con next() (por defecto, el más eficiente)
TYPE_SCROLL_INSENSITIVE Permite moverse libremente; no refleja cambios hechos por otros mientras está abierto
CONCUR_READ_ONLY Solo lectura (por defecto)
CONCUR_UPDATABLE Permite modificar filas con updateXxx + updateRow(), insertar o borrar desde el ResultSet

Métodos de navegación: first(), last(), beforeFirst(), afterLast(), absolute(n), relative(n), previous() y getRow() para saber en qué fila estás.

package es.curso.jdbc;

import java.sql.*;

public class ResultSetDesplazable {

    public static void main(String[] args) throws SQLException {
        try (Connection con = Conexiones.obtener()) {

            // 1. Desplazable (scroll) y de solo lectura
            try (PreparedStatement ps = con.prepareStatement(
                    "SELECT nombre FROM productos ORDER BY precio DESC",
                    ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY);
                 ResultSet rs = ps.executeQuery()) {
                rs.last();                                   // a la última fila
                System.out.println(rs.getRow() + " productos; el más barato: " + rs.getString(1));
                rs.absolute(3);                              // a la fila 3
                System.out.println("El 3.º más caro: " + rs.getString(1));
                rs.previous();                               // una hacia atrás
                System.out.println("El 2.º más caro: " + rs.getString(1));
            }

            // 2. Actualizable: la consulta debe ser de una tabla e incluir su clave primaria
            try (PreparedStatement ps = con.prepareStatement(
                    "SELECT producto_id, nombre, stock FROM productos WHERE stock = 0",
                    ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_UPDATABLE);
                 ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    rs.updateInt("stock", 5);
                    rs.updateRow();                          // envía el UPDATE a la BD
                    System.out.println("Repuesto: " + rs.getString("nombre"));
                }
            }

            // Dejamos los datos como estaban
            try (Statement st = con.createStatement()) {
                System.out.println("Restaurados: "
                        + st.executeUpdate("UPDATE productos SET stock = 0 WHERE producto_id IN (8, 14)"));
            }
        }
    }
}
20 productos; el más barato: Ratón inalámbrico
El 3.º más caro: Smartphone Gama Alta
El 2.º más caro: Portátil Gaming 16
Repuesto: Pack teclado y ratón
Repuesto: Altavoz Inteligente
Restaurados: 2

⚠️ Importante: Con MySQL, un ResultSet actualizable exige que la consulta sea sobre una sola tabla e incluya su clave primaria (por eso seleccionamos producto_id). Además, ten en cuenta que Connector/J, por defecto, descarga en memoria el resultado completo de las consultas (desplazables o no); para recorrer millones de filas hay que activar la lectura por bloques (useCursorFetch=true con setFetchSize). Los ResultSet desplazables y actualizables son útiles en programas sencillos, pero en una aplicación real es más claro hacer SELECT y UPDATE explícitos con PreparedStatement.


14. Errores: SQLException

Casi todos los métodos de JDBC lanzan SQLException, una excepción comprobada. Además del mensaje, trae información muy útil para decidir qué hacer:

Método Qué devuelve Ejemplo
getMessage() Descripción del error (del servidor o del driver) Duplicate entry 'VENTAS' for key ...
getErrorCode() Código de error propio del fabricante 1062 en MySQL (clave duplicada)
getSQLState() Código estándar de 5 caracteres (ISO/ANSI SQL y X/Open) 23000 (violación de integridad)

JDBC 4 añadió una jerarquía de subclases para no tener que mirar códigos a mano: SQLIntegrityConstraintViolationException, SQLSyntaxErrorException, SQLTransientConnectionException, SQLTimeoutException… Este programa provoca a propósito los errores más frecuentes contra EMPRESA:

package es.curso.jdbc;

import java.sql.*;

public class ErroresSql {

    @FunctionalInterface
    interface AccionSql {
        void ejecutar() throws SQLException;
    }

    static void probar(String descripcion, AccionSql accion) {
        try {
            accion.ejecutar();
            System.out.println(descripcion + ": OK");
        } catch (SQLIntegrityConstraintViolationException e) {
            // Subclase específica para violaciones de restricciones (SQLState 23xxx)
            System.out.printf("%s%n  [restricción] %s | código=%d | SQLState=%s%n  %s%n",
                    descripcion, e.getClass().getSimpleName(), e.getErrorCode(), e.getSQLState(),
                    e.getMessage());
        } catch (SQLException e) {
            System.out.printf("%s%n  %s | código=%d | SQLState=%s%n  %s%n",
                    descripcion, e.getClass().getSimpleName(), e.getErrorCode(), e.getSQLState(),
                    e.getMessage().lines().findFirst().orElse(""));
        }
    }

    public static void main(String[] args) throws SQLException {
        try (Connection con = Conexiones.obtener();
             Statement st = con.createStatement()) {

            probar("1. Nombre de departamento duplicado", () -> st.executeUpdate(
                    "INSERT INTO departamentos (nombre, localidad) VALUES ('VENTAS', 'Lugo')"));
            probar("2. Clave ajena inexistente", () -> st.executeUpdate(
                    "UPDATE empleados SET dept_id = 99 WHERE emp_id = 160"));
            probar("3. Restricción CHECK (salario > 0)", () -> st.executeUpdate(
                    "UPDATE empleados SET salario = -5 WHERE emp_id = 160"));
            probar("4. Tabla que no existe", () -> st.executeQuery("SELECT * FROM empleado"));
            probar("5. Error de sintaxis", () -> st.executeQuery("SELECT * FORM empleados"));
        }

        probar("6. Contraseña incorrecta", () -> DriverManager.getConnection(
                "jdbc:mysql://localhost:3306/empresa", "curso", "mala").close());
        probar("7. Servidor inaccesible", () -> DriverManager.getConnection(
                "jdbc:mysql://localhost:3307/empresa?connectTimeout=2000", "curso", "curso").close());
    }
}
1. Nombre de departamento duplicado
  [restricción] SQLIntegrityConstraintViolationException | código=1062 | SQLState=23000
  Duplicate entry 'VENTAS' for key 'departamentos.uq_dep_nombre'
2. Clave ajena inexistente
  [restricción] SQLIntegrityConstraintViolationException | código=1452 | SQLState=23000
  Cannot add or update a child row: a foreign key constraint fails (`empresa`.`empleados`, CONSTRAINT `fk_emp_dept` FOREIGN KEY (`dept_id`) REFERENCES `departamentos` (`dept_id`))
3. Restricción CHECK (salario > 0)
  SQLException | código=3819 | SQLState=HY000
  Check constraint 'ck_emp_salario' is violated.
4. Tabla que no existe
  SQLSyntaxErrorException | código=1146 | SQLState=42S02
  Table 'empresa.empleado' doesn't exist
5. Error de sintaxis
  SQLSyntaxErrorException | código=1064 | SQLState=42000
  You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'FORM empleados' at line 1
6. Contraseña incorrecta
  SQLException | código=1045 | SQLState=28000
  Access denied for user 'curso'@'172.31.0.6' (using password: YES)
7. Servidor inaccesible
  CommunicationsException | código=0 | SQLState=08S01
  Communications link failure

Fíjate en el caso 3: la violación de un CHECK llega con SQLState HY000 (error genérico) y no como SQLIntegrityConstraintViolationException. No todos los errores encajan donde esperarías; por eso conviene conocer los códigos de los casos que tu aplicación debe tratar de forma especial.

Las dos primeras letras del SQLState indican la clase de error, y son las mismas en todas las bases de datos:

Clase Significado Ejemplo
08 Error de conexión 08S01 enlace de comunicación caído
22 Error en los datos 22001 cadena demasiado larga, 22012 división por cero
23 Violación de integridad 23000 clave duplicada o clave ajena
28 Autorización incorrecta 28000 acceso denegado
40 Rollback de transacción 40001 interbloqueo o espera de bloqueo agotada (módulo 13)
42 Error de sintaxis o de acceso 42000 sintaxis, 42S02 tabla inexistente
HY Error genérico del driver/servidor HY000

💡 Consejo: Nunca escribas catch (SQLException e) { } vacío ni te limites a e.printStackTrace(). Decide en cada caso: si es un error que el usuario puede corregir (email duplicado), tradúcelo a un mensaje claro; si no, regístralo y relánzalo (o envuélvelo en una excepción propia, como haremos con DataAccessException en el módulo 13).


15. Herramientas para ver la base de datos

Mientras programas con JDBC necesitas comprobar qué hay realmente en las tablas. Estas son las tres herramientas gráficas más habituales; todas se conectan con los mismos datos: localhost, puerto 3306, base de datos empresa, usuario curso y contraseña curso.

15.1 IntelliJ IDEA

Abre la ventana Database (barra lateral derecha o View → Tool Windows → Database) y pulsa + → Data Source → MySQL. Se abre el diálogo Data Sources and Drivers:

Esquema del diálogo Data Sources and Drivers de IntelliJ IDEA con una conexión MySQL a empresa

Figura 7. Esquema del diálogo Data Sources and Drivers de IntelliJ IDEA (pestaña General).

  1. Host localhost y Port 3306.
  2. User curso y Password curso (con Authentication: User & Password; Save: Forever la recuerda).
  3. Database empresa.
  4. La URL se genera sola a partir de los campos anteriores: es la misma URL JDBC que usas en tu código.
  5. La primera vez aparece el aviso Download missing driver files: pulsa para que el IDE descargue su propia copia de Connector/J (independiente de la de tu pom.xml).
  6. Test Connection comprueba que todo es correcto antes de pulsar OK.

Esquema de la ventana Database de IntelliJ IDEA con el árbol de tablas de empresa

Figura 8. Esquema de la ventana Database de IntelliJ IDEA con el árbol de EMPRESA.

La ventana Database (1) se abre desde la barra lateral; su botón + (2) crea conexiones. Debajo del data source empresa@localhost (3) está el esquema empresa con sus 9 tablas (4), y cada tabla muestra sus columnas con el tipo y auto increment (5), claves, índices y restricciones CHECK.

💡 Consejo: Desde la versión 2025.3, IntelliJ IDEA es una única distribución: sin suscripción puedes conectarte y explorar el esquema (tablas, columnas, índices), mientras que ejecutar SQL y ver o editar los datos desde el IDE requiere la suscripción Ultimate. JetBrains ofrece licencias gratuitas para estudiantes y docentes. Si no la tienes, usa DBeaver Community, que es gratuito.

15.2 DBeaver

DBeaver Community es gratuito y multiplataforma. Pulsa Database → New Database Connection, elige MySQL y rellena la página de ajustes:

Esquema del diálogo de conexión MySQL de DBeaver

Figura 9. Esquema del asistente Connect to a database de DBeaver para MySQL.

  1. Server Host localhost y Port 3306 (con Connect by: Host).
  2. Database empresa.
  3. Username curso y Password curso (Authentication: Database Native).
  4. La pestaña Driver properties permite añadir propiedades de Connector/J, como allowPublicKeyRetrieval=true si aparece el error Public Key Retrieval is not allowed.
  5. Test Connection … (la primera vez ofrece descargar el driver) y después Finish.

15.3 MySQL Workbench

Es la herramienta oficial de MySQL. En la pantalla inicial pulsa + junto a MySQL Connections:

Esquema del diálogo Setup New Connection de MySQL Workbench

Figura 10. Esquema del diálogo Setup New Connection de MySQL Workbench.

  1. Connection Name: un nombre descriptivo, por ejemplo EMPRESA (Docker).
  2. Hostname 127.0.0.1 y Port 3306 (Connection Method: Standard (TCP/IP)).
  3. Username curso.
  4. Store in Vault … guarda la contraseña curso en el almacén seguro del sistema.
  5. Default Schema empresa.
  6. Test Connection y OK.

15.4 El cliente de línea de comandos

La opción más directa viene dentro del contenedor: docker exec -it mysql-empresa mysql --default-character-set=utf8mb4 -ucurso -p empresa y, una vez dentro, SHOW TABLES;, DESCRIBE empleados; o cualquier SELECT.


16. Buenas prácticas y errores comunes

Error común Consecuencia Buena práctica
No cerrar conexiones, sentencias o ResultSet Fugas de recursos, Too many connections try-with-resources siempre
Concatenar datos del usuario en el SQL Inyección SQL PreparedStatement con parámetros ?
Usar double para importes Errores de redondeo BigDecimal con getBigDecimal/setBigDecimal
Usar getInt en columnas que admiten NULL sin mirar wasNull() Un NULL se convierte en 0 sin avisar getObject(col, Integer.class) y tipos objeto en el modelo
Contraseñas en el código Credenciales filtradas en Git db.properties fuera del repositorio o variables de entorno
catch (SQLException e) {} vacío Errores silenciosos imposibles de depurar Tratar, traducir o relanzar; nunca ignorar
Abrir una conexión nueva para cada consulta en una aplicación con muchos usuarios Lentitud (abrir conexiones es caro) Un pool de conexiones (HikariCP, módulo 13)
No comprobar las filas afectadas Actualizaciones que no hacen nada sin que nadie se entere Comprobar el int de executeUpdate

17. Ejercicios

Todos los ejercicios usan la base de datos EMPRESA y la clase Conexiones del apartado 4.4. Los que modifican datos los dejan como estaban al terminar; si algo se tuerce, recarga los scripts (apartado 2.2).

Ejercicio 1: Productos con poco stock

Escribe un programa que muestre los productos no descatalogados con menos de 10 unidades en stock, ordenados de menos a más stock. Para cada uno muestra su id, nombre, el nombre de su categoría, precio y stock, y marca con ¡AGOTADO! los que tienen stock 0. Guarda cada fila en un record ProductoStock.

Ver solución
package es.curso.jdbc.ejercicios;

import es.curso.jdbc.Conexiones;

import java.math.BigDecimal;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;

public class Ej1StockBajo {

    record ProductoStock(int id, String nombre, String categoria, BigDecimal precio, int stock) { }

    static List<ProductoStock> stockBajo(Connection con, int umbral) throws SQLException {
        String sql = """
                SELECT p.producto_id, p.nombre, c.nombre AS categoria, p.precio, p.stock
                FROM productos p
                JOIN categorias c ON c.categoria_id = p.categoria_id
                WHERE p.stock < ? AND p.descatalogado = 'N'
                ORDER BY p.stock, p.nombre""";
        List<ProductoStock> resultado = new ArrayList<>();
        try (PreparedStatement ps = con.prepareStatement(sql)) {
            ps.setInt(1, umbral);
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    resultado.add(new ProductoStock(rs.getInt("producto_id"), rs.getString("nombre"),
                            rs.getString("categoria"), rs.getBigDecimal("precio"), rs.getInt("stock")));
                }
            }
        }
        return resultado;
    }

    public static void main(String[] args) throws SQLException {
        int umbral = 10;
        try (Connection con = Conexiones.obtener()) {
            List<ProductoStock> productos = stockBajo(con, umbral);
            System.out.println("Productos con menos de " + umbral + " unidades: " + productos.size());
            for (ProductoStock p : productos) {
                String aviso = (p.stock() == 0) ? "  <-- ¡AGOTADO!" : "";
                System.out.printf("  %2d %-22s %-22s %8s € %3d uds%s%n", p.id(), p.nombre(),
                        p.categoria(), p.precio(), p.stock(), aviso);
            }
        }
    }
}
Productos con menos de 10 unidades: 5
   8 Pack teclado y ratón   Ratones y teclados        34.50 €   0 uds  <-- ¡AGOTADO!
   5 Torre Workstation      Ordenadores sobremesa   1899.00 €   4 uds
  16 Mesa Elevable          Mobiliario               399.00 €   6 uds
  12 Smartphone Gama Alta   Telefonía                999.00 €   8 uds
  18 Impresora Láser        Periféricos              189.00 €   9 uds

El umbral va como parámetro aunque sea una constante: así el método sirve para cualquier valor. El Altavoz Inteligente (stock 0) no aparece porque está descatalogado.

Ejercicio 2: Buscador interactivo de clientes por provincia

Haz un programa que pida por teclado una provincia, muestre sus clientes (nombre, ciudad, límite de crédito y nombre completo de su comercial, o (sin comercial) si no tiene) y vuelva a preguntar hasta que el usuario deje la línea vacía. Prepara la sentencia una sola vez y reutilízala en cada búsqueda.

Ver solución
package es.curso.jdbc.ejercicios;

import es.curso.jdbc.Conexiones;

import java.sql.*;
import java.util.Scanner;

public class Ej2ClientesProvincia {

    public static void main(String[] args) throws SQLException {
        String sql = """
                SELECT c.nombre, c.ciudad, c.limite_credito,
                       COALESCE(CONCAT(e.nombre, ' ', e.apellidos), '(sin comercial)') AS comercial
                FROM clientes c
                LEFT JOIN empleados e ON e.emp_id = c.comercial_id
                WHERE c.provincia = ?
                ORDER BY c.nombre""";

        Scanner teclado = new Scanner(System.in);
        try (Connection con = Conexiones.obtener();
             PreparedStatement ps = con.prepareStatement(sql)) {
            while (true) {
                System.out.print("Provincia (vacío para salir): ");
                if (!teclado.hasNextLine()) break;
                String provincia = teclado.nextLine().trim();
                if (provincia.isEmpty()) break;

                ps.setString(1, provincia);
                try (ResultSet rs = ps.executeQuery()) {
                    int n = 0;
                    while (rs.next()) {
                        n++;
                        System.out.printf("  %-28s %-10s %9s €  comercial: %s%n", rs.getString("nombre"),
                                rs.getString("ciudad"), rs.getBigDecimal("limite_credito"),
                                rs.getString("comercial"));
                    }
                    if (n == 0) {
                        System.out.println("  No hay clientes en " + provincia);
                    }
                }
            }
        }
        System.out.println("¡Hasta luego!");
    }
}

Ejecución escribiendo Sevilla, Madrid, Cuenca y una línea vacía:

Provincia (vacío para salir): Sevilla
  Antonio Ruiz Pérez           Sevilla      1500.00 €  comercial: Elena Navarro Castro
  Clínica Dental Sonrisa       Sevilla      6000.00 €  comercial: Elena Navarro Castro
Provincia (vacío para salir): Madrid
  Academia Nova                Madrid       8000.00 €  comercial: David Torres Romero
  María José Herrera           Madrid       2000.00 €  comercial: David Torres Romero
Provincia (vacío para salir): Cuenca
  No hay clientes en Cuenca
Provincia (vacío para salir): 
¡Hasta luego!

El COALESCE resuelve en SQL el caso de los clientes sin comercial, y el contador n permite avisar cuando no hay resultados. Prueba a escribir x' OR '1'='1 como provincia: no devolverá nada.

Ejercicio 3: Alta de un cliente con su id generado

Crea un record Cliente (con Integer id) y un método insertar(Connection, Cliente) que guarde el cliente y devuelva una copia con el id generado. El email y el comercial pueden ser null. Después intenta insertarlo otra vez para provocar el error de NIF duplicado y, por último, bórralo.

Ver solución
package es.curso.jdbc.ejercicios;

import es.curso.jdbc.Conexiones;

import java.math.BigDecimal;
import java.sql.*;

public class Ej3AltaCliente {

    record Cliente(Integer id, String nombre, String nif, String email, String ciudad,
                   String provincia, BigDecimal limiteCredito, Integer comercialId) { }

    static Cliente insertar(Connection con, Cliente c) throws SQLException {
        String sql = """
                INSERT INTO clientes (nombre, nif, email, ciudad, provincia, limite_credito, comercial_id)
                VALUES (?, ?, ?, ?, ?, ?, ?)""";
        try (PreparedStatement ps = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, c.nombre());
            ps.setString(2, c.nif());
            ps.setString(3, c.email());              // setString(null) envía NULL
            ps.setString(4, c.ciudad());
            ps.setString(5, c.provincia());
            ps.setBigDecimal(6, c.limiteCredito());
            ps.setObject(7, c.comercialId(), Types.INTEGER);
            ps.executeUpdate();
            try (ResultSet rs = ps.getGeneratedKeys()) {
                rs.next();
                // Los records son inmutables: devolvemos una copia con el id generado
                return new Cliente(rs.getInt(1), c.nombre(), c.nif(), c.email(), c.ciudad(),
                        c.provincia(), c.limiteCredito(), c.comercialId());
            }
        }
    }

    public static void main(String[] args) throws SQLException {
        try (Connection con = Conexiones.obtener()) {
            Cliente nuevo = new Cliente(null, "Librería El Quijote", "B11223344", null,
                    "Toledo", "Toledo", new BigDecimal("3500.00"), 112);
            Cliente guardado = insertar(con, nuevo);
            System.out.println("Cliente insertado con id " + guardado.id());

            try {
                insertar(con, nuevo);      // mismo NIF: viola uq_cli_nif
            } catch (SQLIntegrityConstraintViolationException e) {
                System.out.println("No se puede repetir el NIF: " + e.getMessage());
            }

            try (PreparedStatement ps = con.prepareStatement("DELETE FROM clientes WHERE cliente_id = ?")) {
                ps.setInt(1, guardado.id());
                System.out.println("Limpieza: " + ps.executeUpdate() + " cliente borrado");
            }
        }
    }
}
Cliente insertado con id 1015
No se puede repetir el NIF: Duplicate entry 'B11223344' for key 'clientes.uq_cli_nif'
Limpieza: 1 cliente borrado

Como fecha_alta no aparece en el INSERT, MySQL aplica su DEFAULT (CURRENT_DATE). El error de NIF duplicado se captura con la subclase específica SQLIntegrityConstraintViolationException.

Ejercicio 4: Facturación de un cliente

Escribe un método que, dado un cliente, devuelva una lista de records PedidoResumen(id, fecha, estado, lineas, importe) con todos sus pedidos. El importe de cada pedido es la suma de cantidad × precio_unitario × (1 − descuento/100) de sus líneas. Muéstralos para el cliente 1002 y calcula con un stream el total facturado sin contar los pedidos cancelados.

Ver solución
package es.curso.jdbc.ejercicios;

import es.curso.jdbc.Conexiones;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public class Ej4FacturacionCliente {

    record PedidoResumen(int id, LocalDate fecha, String estado, int lineas, BigDecimal importe) { }

    static List<PedidoResumen> pedidosDeCliente(Connection con, int clienteId) throws SQLException {
        String sql = """
                SELECT p.pedido_id, p.fecha_pedido, p.estado, COUNT(*) AS lineas,
                       SUM(l.cantidad * l.precio_unitario * (1 - l.descuento / 100)) AS importe
                FROM pedidos p
                JOIN lineas_pedido l ON l.pedido_id = p.pedido_id
                WHERE p.cliente_id = ?
                GROUP BY p.pedido_id, p.fecha_pedido, p.estado
                ORDER BY p.fecha_pedido""";
        List<PedidoResumen> lista = new ArrayList<>();
        try (PreparedStatement ps = con.prepareStatement(sql)) {
            ps.setInt(1, clienteId);
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    lista.add(new PedidoResumen(rs.getInt("pedido_id"),
                            rs.getObject("fecha_pedido", LocalDate.class), rs.getString("estado"),
                            rs.getInt("lineas"),
                            rs.getBigDecimal("importe").setScale(2, RoundingMode.HALF_UP)));
                }
            }
        }
        return lista;
    }

    public static void main(String[] args) throws SQLException {
        int clienteId = 1002;   // Construcciones Levante S.A.
        try (Connection con = Conexiones.obtener()) {
            List<PedidoResumen> pedidos = pedidosDeCliente(con, clienteId);
            pedidos.forEach(p -> System.out.printf("Pedido %d  %s  %-10s %d líneas  %10s €%n",
                    p.id(), p.fecha(), p.estado(), p.lineas(), p.importe()));
            BigDecimal total = pedidos.stream()
                    .filter(p -> !p.estado().equals("CANCELADO"))
                    .map(PedidoResumen::importe)
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
            System.out.println("Total facturado al cliente " + clienteId + ": " + total + " €");
        }
    }
}
Pedido 5002  2025-01-28  ENTREGADO  3 líneas     7402.50 €
Pedido 5009  2025-05-19  ENTREGADO  3 líneas     4772.44 €
Pedido 5018  2026-03-03  ENTREGADO  2 líneas     2085.25 €
Total facturado al cliente 1002: 14260.19 €

La agregación (SUM, GROUP BY) se hace en la base de datos, que es donde es más eficiente; Java solo recibe una fila por pedido. El cálculo del total, en cambio, se hace en memoria con un stream sobre los records. MySQL calcula el importe con más decimales, por eso redondeamos a céntimos con setScale(2, HALF_UP).

Ejercicio 5: Describir una tabla con metadatos

Escribe un método describir(Connection, String tabla) que, usando DatabaseMetaData, compruebe que la tabla existe, liste sus columnas indicando cuáles forman la clave primaria, el tipo completo (DECIMAL(8,2), VARCHAR(60)…) y si son NOT NULL, y cuente sus filas. Pruébalo con pedidos y con una tabla inexistente.

Ver solución
package es.curso.jdbc.ejercicios;

import es.curso.jdbc.Conexiones;

import java.sql.*;
import java.util.Set;
import java.util.TreeSet;

public class Ej5DescribirTabla {

    static Set<String> tablas(DatabaseMetaData md, String esquema) throws SQLException {
        Set<String> nombres = new TreeSet<>();
        try (ResultSet rs = md.getTables(esquema, null, "%", new String[]{"TABLE"})) {
            while (rs.next()) {
                nombres.add(rs.getString("TABLE_NAME"));
            }
        }
        return nombres;
    }

    static void describir(Connection con, String tabla) throws SQLException {
        DatabaseMetaData md = con.getMetaData();
        String esquema = con.getCatalog();
        // Lista blanca: el nombre de tabla no puede ir en un ?, así que lo validamos contra los metadatos
        if (!tablas(md, esquema).contains(tabla)) {
            System.out.println("La tabla '" + tabla + "' no existe. Tablas: " + tablas(md, esquema));
            return;
        }
        Set<String> pk = new TreeSet<>();
        try (ResultSet rs = md.getPrimaryKeys(esquema, null, tabla)) {
            while (rs.next()) pk.add(rs.getString("COLUMN_NAME"));
        }
        System.out.println("Tabla " + tabla);
        try (ResultSet rs = md.getColumns(esquema, null, tabla, "%")) {
            while (rs.next()) {
                String col = rs.getString("COLUMN_NAME");
                String tipo = rs.getString("TYPE_NAME");
                int tam = rs.getInt("COLUMN_SIZE");
                int dec = rs.getInt("DECIMAL_DIGITS");
                String tipoCompleto = switch (tipo) {
                    case "DECIMAL" -> tipo + "(" + tam + "," + dec + ")";
                    case "VARCHAR", "CHAR" -> tipo + "(" + tam + ")";
                    default -> tipo;
                };
                System.out.printf("  %-3s %-16s %-14s %s%n", pk.contains(col) ? "PK" : "",
                        col, tipoCompleto, "NO".equals(rs.getString("IS_NULLABLE")) ? "NOT NULL" : "");
            }
        }
        try (Statement st = con.createStatement();
             ResultSet rs = st.executeQuery("SELECT COUNT(*) FROM " + tabla)) {   // seguro: validado
            rs.next();
            System.out.println("  Filas: " + rs.getLong(1));
        }
    }

    public static void main(String[] args) throws SQLException {
        try (Connection con = Conexiones.obtener()) {
            describir(con, "pedidos");
            describir(con, "facturas");
        }
    }
}
Tabla pedidos
  PK  pedido_id        INT            NOT NULL
      cliente_id       INT            NOT NULL
      empleado_id      INT            
      fecha_pedido     DATE           NOT NULL
      fecha_envio      DATE           
      estado           VARCHAR(12)    NOT NULL
  Filas: 24
La tabla 'facturas' no existe. Tablas: [asignaciones, categorias, clientes, departamentos, empleados, lineas_pedido, pedidos, productos, proyectos]

El nombre de la tabla se concatena en el SELECT COUNT(*), pero solo después de comprobar que está en la lista de tablas reales: es la técnica de la lista blanca del apartado 7.4. El switch con flechas (Java 14) construye el tipo completo.

Ejercicio 6: Subida salarial con control de errores

Escribe ajustarSalario(Connection, String oficio, BigDecimal porcentaje), que suba (o baje, si el porcentaje es negativo) el salario de todos los empleados de un oficio y devuelva cuántos se han actualizado. Pruébalo con una subida del 3,5 % a los programadores, con un oficio inexistente y con una “bajada” del −100 %, que debe fallar por el CHECK (salario > 0). Captura el error mostrando su código y su SQLState y, al final, restaura los salarios originales.

Ver solución
package es.curso.jdbc.ejercicios;

import es.curso.jdbc.Conexiones;

import java.math.BigDecimal;
import java.sql.*;

public class Ej6SubidaSalarial {

    /** Sube (o baja, si es negativo) el salario de todos los empleados de un oficio. */
    static int ajustarSalario(Connection con, String oficio, BigDecimal porcentaje) throws SQLException {
        String sql = "UPDATE empleados SET salario = ROUND(salario * (1 + ? / 100), 2) WHERE oficio = ?";
        try (PreparedStatement ps = con.prepareStatement(sql)) {
            ps.setBigDecimal(1, porcentaje);
            ps.setString(2, oficio);
            return ps.executeUpdate();
        }
    }

    static void mostrar(Connection con, String oficio) throws SQLException {
        try (PreparedStatement ps = con.prepareStatement(
                "SELECT nombre, salario FROM empleados WHERE oficio = ? ORDER BY emp_id")) {
            ps.setString(1, oficio);
            try (ResultSet rs = ps.executeQuery()) {
                StringBuilder sb = new StringBuilder("  ");
                while (rs.next()) {
                    sb.append(rs.getString(1)).append('=').append(rs.getBigDecimal(2)).append("  ");
                }
                System.out.println(sb);
            }
        }
    }

    static void intentar(Connection con, String oficio, String porcentaje) {
        try {
            int filas = ajustarSalario(con, oficio, new BigDecimal(porcentaje));
            System.out.println(oficio + " " + porcentaje + " %: " + filas + " empleado(s) actualizados");
        } catch (SQLException e) {
            System.out.println(oficio + " " + porcentaje + " %: ERROR " + e.getErrorCode()
                    + " (" + e.getSQLState() + ") " + e.getMessage());
        }
    }

    public static void main(String[] args) throws SQLException {
        try (Connection con = Conexiones.obtener()) {
            mostrar(con, "PROGRAMADOR");
            intentar(con, "PROGRAMADOR", "3.5");
            mostrar(con, "PROGRAMADOR");
            intentar(con, "ASTRONAUTA", "10");
            intentar(con, "PROGRAMADOR", "-100");    // dejaría el salario a 0: viola el CHECK

            // Restauramos los salarios originales para no alterar EMPRESA
            try (PreparedStatement ps = con.prepareStatement(
                    "UPDATE empleados SET salario = CASE emp_id WHEN 121 THEN 2800 WHEN 122 THEN 2600 "
                            + "WHEN 123 THEN 2400 END WHERE emp_id IN (121, 122, 123)")) {
                System.out.println("Restaurados: " + ps.executeUpdate());
            }
            mostrar(con, "PROGRAMADOR");
        }
    }
}
  Raquel=2800.00  Iván=2600.00  Sara=2400.00  
PROGRAMADOR 3.5 %: 3 empleado(s) actualizados
  Raquel=2898.00  Iván=2691.00  Sara=2484.00  
ASTRONAUTA 10 %: 0 empleado(s) actualizados
PROGRAMADOR -100 %: ERROR 3819 (HY000) Check constraint 'ck_emp_salario' is violated.
Restaurados: 3
  Raquel=2800.00  Iván=2600.00  Sara=2400.00  

El cálculo se hace en el propio UPDATE (una sola sentencia para todos los empleados), lo que es mucho más eficiente que leer cada salario, calcularlo en Java y escribirlo. Un oficio inexistente no es un error: simplemente afecta a 0 filas. Cuando una sentencia viola una restricción, MySQL la rechaza entera: ningún salario queda a medias.

Ejercicio 7: Proyectos y horas (propuesto)

Muestra cada proyecto con el nombre de su departamento, el número de empleados asignados y el total de horas, incluidos los proyectos sin asignaciones (deben salir con 0 empleados y 0 horas). Guarda cada fila en un record ResumenProyecto y ordena por horas de mayor a menor.

Ejercicio 8: Buscador de empleados por apellido (propuesto)

Pide un texto por teclado y muestra los empleados cuyo apellido contenga ese texto, sin distinguir mayúsculas de minúsculas, con su oficio y departamento. Usa LIKE con parámetro y comprueba que introducir % o _ no rompe la búsqueda (pista: investiga la cláusula ESCAPE o escapa esos caracteres en Java antes de añadir los comodines).

Ejercicio 9: Organigrama (propuesto)

Usando la autorrelación jefe_id, escribe un método recursivo que imprima el organigrama de la empresa a partir de la directora general (empleada 100), con sangría según el nivel. Hazlo primero con una consulta por cada empleado y después cargando todos los empleados en una List<Empleado> con una sola consulta y construyendo el árbol en memoria. ¿Cuántas consultas lanza cada versión?

Ejercicio 10: Exportar cualquier consulta a CSV (propuesto)

Escribe una utilidad ExportadorCsv.exportar(ResultSet rs, Path fichero) que, usando ResultSetMetaData, sirva para cualquier consulta, que escriba la cabecera con las etiquetas de las columnas y una línea por fila, usando ; como separador y entrecomillando los textos que contengan ; o comillas. Los NULL deben quedar como campo vacío. Exporta con ella el resultado de un JOIN de pedidos con sus clientes (usa NIO.2, módulo 08).

Ejercicio 11: CRUD de categorías (propuesto)

Implementa una clase CategoriasJdbc con los métodos insertar(String nombre, Integer padreId) (que devuelva el id generado), buscarPorId, listarHijas(int padreId), renombrar(int id, String nuevo) y borrar(int id). Prueba qué ocurre al borrar una categoría que tiene subcategorías o productos y traduce ese error a un mensaje comprensible.

Ejercicio 12: Menú de consulta de EMPRESA (propuesto)

Crea un programa con un menú de consola que permita: listar departamentos, ver los empleados de un departamento, ver el detalle de un pedido (cabecera, líneas con el nombre de cada producto e importe total) y salir. Reutiliza los records y métodos de los ejercicios anteriores y gestiona los errores para que una SQLException no termine el programa. Guárdalo: en el módulo 13 lo reorganizarás en capas.


Resumen

Concepto Sintaxis / clave
Dependencia del driver com.mysql:mysql-connector-j:26.7.0
URL de conexión jdbc:mysql://localhost:3306/empresa?propiedad=valor
Abrir conexión DriverManager.getConnection(url, usuario, clave) dentro de try-with-resources
Consulta executeQuery(sql) → ResultSet
Modificación executeUpdate(sql) → filas afectadas
Recorrer resultados while (rs.next()) { rs.getXxx("columna") } (índices desde 1)
NULL getInt devuelve 0 → wasNull(); mejor getObject(col, Integer.class)
Fechas rs.getObject(col, LocalDate.class), ps.setObject(i, localDate)
Dinero BigDecimal con getBigDecimal / setBigDecimal
Sentencia con parámetros con.prepareStatement("... WHERE id = ?") + ps.setInt(1, id)
Contra la inyección SQL Nunca concatenar datos externos; PreparedStatement siempre
Clave generada prepareStatement(sql, Statement.RETURN_GENERATED_KEYS) + getGeneratedKeys()
Mapear filas Método mapear(ResultSet) → record; Optional para búsquedas por id
Procedimientos con.prepareCall("{call proc(?, ?)}") + registerOutParameter
Metadatos con.getMetaData() (getTables, getColumns, getPrimaryKeys), rs.getMetaData()
ResultSet desplazable / actualizable TYPE_SCROLL_INSENSITIVE + last/absolute/previous; CONCUR_UPDATABLE + updateXxx + updateRow()
Errores SQLException: getErrorCode() (fabricante), getSQLState() (estándar) y subclases