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
DriverManagery 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 unResultSet - Convertir correctamente los tipos SQL a Java, tratar los
NULLy trabajar con fechas dejava.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…), hacerJOIN, llamar a procedimientos, consultar metadatos y usarResultSetdesplazables - 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 paquetesjava.sqlyjavax.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
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.sqlyjavax.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:
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:
empresa_mysql_crear.sql: losCREATE TABLEde las 9 tablas con sus claves y restricciones.empresa_mysql_datos.sql: los datos (7 departamentos, 24 empleados, 14 clientes, 20 productos, 24 pedidos…).empresa_mysql_borrar.sql: losDROP TABLE, en el orden correcto, para empezar de cero.
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
$PWDtambié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 dedocker-entrypoint-initdb.dsolo 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-empresaydocker volume rm mysql-empresa-datos) y vuelve a empezar. La primera vez tarda un poco: sigue el proceso condocker logs -f mysql-empresahasta verready for connections.
💡 Consejo: Si prefieres
docker compose, elcompose.yamlequivalente solo tiene que declarar la imagenmysql:8.4, el puerto3306:3306, las cuatro variablesMYSQL_*y los dos volúmenes (mysql-empresa-datos:/var/lib/mysqly./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 insecurees normal: MySQL te recuerda que la contraseña escrita en el comando queda en el historial. Para trabajar de forma interactiva usadocker exec -it mysql-empresa mysql --default-character-set=utf8mb4 -ucurso -p empresay escribe la contraseña cuando la pida.
⚠️ Importante: El cliente
mysqlde la imagen Docker usa por defecto el juego de caractereslatin1, así que sin--default-character-set=utf8mb4verásDIRECCI?No símbolos raros en lugar de las tildes. Los datos están bien guardados (los scripts empiezan conSET 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 antiguasmysql:mysql-connector-javaestá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.jardel driver incluye el ficheroMETA-INF/services/java.sql.DriveryDriverManagerlo descubre solo con el mecanismoServiceLoader. Basta con que el driver esté en el classpath.
4. La conexión
4.1 La URL de conexión
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 conexionesclose()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.ejemplosin contraseñas y añade eldb.propertiesde 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
UPDATEde los precios es definitivo nada más ejecutarse y hemos tenido que deshacerlo con otroUPDATE. 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
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()produceSQLException: Before start of result set. Y por defecto elResultSetes 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 siempreBigDecimal. Condoubleaparecen 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 admitenNULL(comocomision,jefe_idodept_iden empleados) y léelas congetObject(..., Clase). Así elNULLde la base de datos se convierte ennullde 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
DATETIMEyTIMESTAMPentra en juego la zona horaria.LocalDate/LocalDateTimeno tienen zona y se guardan “tal cual”; si trabajas con instantes (Instant,OffsetDateTime) configuraconnectionTimeZoneen la URL para que el driver sepa cómo convertir. Para las columnasDATEde 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)
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 propiedadallowMultiQueriesvalefalse), 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
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
PreparedStatementcon parámetros.Statementsolo 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
borrarconcatena 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 losnullsorpresa. Es exactamente lo que hacen los repositorios de Spring Data confindById. 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 JOINaparece igualmente, condepartamentoylocalidadaNULL(que el programa muestra como “(ninguno)” y “-“). - INVESTIGACIÓN no tiene empleados:
COUNT(e.emp_id)da 0 yAVGdaNULL, por eso comprobamosmedia == null.
⚠️ Importante: Si en un
JOINdos 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 clientemysql: cadaexecuteenvía una sentencia completa. Si el procedimiento devuelve filas con unSELECT,execute()devuelvetruey las lees concs.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(decon.getMetaData()): producto, versión, tablas, columnas, claves primarias y ajenas, capacidades del driver…ResultSetMetaData(ders.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:
getColumnNamedevuelve el nombre real de la columna en la tabla ygetColumnLabelel alias delAS(o el nombre si no hay alias). Para mostrar resultados usa siempregetColumnLabel.
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
ResultSetactualizable exige que la consulta sea sobre una sola tabla e incluya su clave primaria (por eso seleccionamosproducto_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=trueconsetFetchSize). LosResultSetdesplazables y actualizables son útiles en programas sencillos, pero en una aplicación real es más claro hacerSELECTyUPDATEexplícitos conPreparedStatement.
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 ae.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 conDataAccessExceptionen 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:
Figura 7. Esquema del diálogo Data Sources and Drivers de IntelliJ IDEA (pestaña General).
- Host
localhosty Port3306. - User
cursoy Passwordcurso(con Authentication: User & Password; Save: Forever la recuerda). - Database
empresa. - La URL se genera sola a partir de los campos anteriores: es la misma URL JDBC que usas en tu código.
- 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). - Test Connection comprueba que todo es correcto antes de pulsar OK.
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:
Figura 9. Esquema del asistente Connect to a database de DBeaver para MySQL.
- Server Host
localhosty Port3306(con Connect by: Host). - Database
empresa. - Username
cursoy Passwordcurso(Authentication: Database Native). - La pestaña Driver properties permite añadir propiedades de Connector/J, como
allowPublicKeyRetrieval=truesi aparece el error Public Key Retrieval is not allowed. - 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:
Figura 10. Esquema del diálogo Setup New Connection de MySQL Workbench.
- Connection Name: un nombre descriptivo, por ejemplo
EMPRESA (Docker). - Hostname
127.0.0.1y Port3306(Connection Method: Standard (TCP/IP)). - Username
curso. - Store in Vault … guarda la contraseña
cursoen el almacén seguro del sistema. - Default Schema
empresa. - 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 |