9.1.-Acceso a BBDD
9.1. Acceso a bases de datos¶
Resumen
En este tema se introduce cómo una aplicación escrita en Kotlin puede almacenar, consultar, modificar y eliminar información en una base de datos relacional. El objetivo no es memorizar llamadas sueltas de JDBC, sino entender el flujo completo: conectar con el sistema gestor, ejecutar operaciones SQL, transformar resultados en objetos y mantener la integridad de los datos.
Una aplicación útil rara vez trabaja solo con datos temporales en memoria. En cuanto necesita recordar usuarios, pedidos, productos, reservas, incidencias o cualquier otra información entre ejecuciones, aparece la necesidad de persistencia. Las bases de datos resuelven ese problema: permiten guardar información de forma organizada, recuperarla cuando hace falta y compartirla entre distintas partes de una aplicación o entre distintas personas usuarias.
En esta unidad trabajaremos con bases de datos relacionales desde Kotlin. Kotlin no tiene una API propia y aislada para bases de datos: aprovecha el ecosistema de Java y, especialmente, JDBC. A partir de ahí se pueden usar capas de mayor nivel, como ORM, JPA o Spring Data, que simplifican parte del trabajo.
La idea clave de este tema es que acceder a una base de datos no consiste solo en "lanzar SQL", sino en gestionar de forma segura todo el ciclo de vida de los datos: conexión, consulta, transformación, validación, errores y cierre de recursos.
Directamente relacionado con la normativa del módulo de Programación, este tema trabaja el resultado de aprendizaje 9. La normativa indica que el alumnado debe ser capaz de gestionar información almacenada en bases de datos manteniendo la integridad y consistencia de los datos.
| Código | Descripción |
|---|---|
| RA9 | Gestiona información almacenada en bases de datos manteniendo la integridad y consistencia de los datos. |
| CE a | Se han identificado las características y métodos de acceso a sistemas gestores de bases de datos. |
| CE b | Se han programado conexiones con bases de datos. |
| CE c | Se ha escrito código para almacenar información en bases de datos. |
| CE d | Se han creado programas para recuperar y mostrar información almacenada en bases de datos. |
| CE e | Se han efectuado borrados y modificaciones sobre la información almacenada. |
| CE f | Se han creado aplicaciones que muestren la información almacenada en bases de datos. |
| CE g | Se han creado aplicaciones para gestionar la información presente en bases de datos. |
1. Qué debe aprender el alumnado¶
Al terminar este tema, deberías poder explicar y aplicar estas ideas:
- Una base de datos permite persistir información de forma organizada, compartida y segura.
- Una aplicación Kotlin puede acceder a una base de datos relacional mediante JDBC o mediante capas de abstracción superiores.
- Las operaciones básicas sobre datos se organizan habitualmente como CRUD: crear, leer, actualizar y eliminar.
- La integridad de los datos depende tanto del diseño de la base de datos como del código que ejecuta conexiones, consultas, transacciones y tratamiento de errores.
2. Bases de datos y persistencia¶
Una base de datos es un conjunto organizado de información que se almacena y se gestiona en un sistema informático. Su finalidad es permitir que los datos se puedan guardar, recuperar, relacionar, actualizar y proteger de forma eficiente.
Dicho de forma sencilla: si una aplicación necesita conservar información cuando se cierra, compartirla con otros procesos o consultarla de forma flexible, necesita algún mecanismo de persistencia. En aplicaciones empresariales, sitios web, aplicaciones móviles o sistemas de escritorio, lo más habitual es utilizar una base de datos.
Las bases de datos son importantes en el desarrollo de software porque permiten:
- Gestionar grandes volúmenes de información sin cargarlo todo en memoria.
- Compartir datos entre varias personas usuarias o servicios.
- Consultar información mediante criterios, filtros, ordenaciones y agregaciones.
- Mantener reglas de integridad, como claves primarias, claves foráneas o restricciones de unicidad.
- Separar la lógica de la aplicación del almacenamiento físico de los datos.
Ejemplo cotidiano
En una aplicación de inventario, los productos, sus precios, las existencias y las ventas no pueden depender de una lista en memoria. Deben almacenarse en una base de datos para que sigan existiendo al cerrar la aplicación, puedan consultarse desde varios equipos y mantengan reglas como "no puede haber dos productos con el mismo código".
Para aterrizarlo, imagina que una tienda guarda sus productos en una colección de Kotlin:
data class Producto(val codigo: String, val nombre: String, val stock: Int)
val productos = mutableListOf(
Producto("P001", "Teclado", 12),
Producto("P002", "Ratón", 20)
)
Este código permite trabajar con productos mientras el programa está en ejecución, pero tiene un problema evidente: si la aplicación se cierra, esa lista se pierde salvo que se guarde en algún soporte persistente. Además, si dos personas modifican el stock al mismo tiempo desde equipos distintos, una lista en memoria no ofrece por sí misma control de concurrencia, permisos ni reglas de integridad.
En una base de datos, esos mismos datos podrían representarse en una tabla:
CREATE TABLE productos (
codigo VARCHAR(10) PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
stock INT NOT NULL CHECK (stock >= 0)
);
En este ejemplo, la base de datos ya impone varias reglas: codigo identifica de forma única cada producto, nombre no puede quedar vacío y stock no puede ser negativo. Esto significa que parte de la calidad de los datos se protege desde el propio almacenamiento, no solo desde el código Kotlin.
3. Bases de datos relacionales y SGBDR¶
Un sistema gestor de bases de datos relacional o SGBDR es el software que permite crear, consultar y mantener una base de datos relacional. También es habitual encontrar la sigla inglesa RDBMS.
En una base de datos relacional, la información se organiza en tablas. Cada tabla contiene filas, que representan registros, y columnas, que representan campos con un tipo de dato. Las relaciones entre tablas se expresan mediante claves primarias y claves foráneas.
Base de datos relacional
Una base de datos relacional organiza la información en tablas relacionadas entre sí. Cada tabla tiene columnas con tipos definidos y filas que representan registros concretos.
Las características principales de un SGBDR son:
- Estructura basada en tablas: los datos se guardan en filas y columnas.
- Relaciones entre tablas: las claves primarias y foráneas permiten vincular información de forma coherente.
- Consultas SQL: el lenguaje SQL permite buscar, filtrar, ordenar, agrupar y modificar información.
- Integridad de los datos: las restricciones evitan estados incoherentes, como referencias a registros inexistentes.
- Escalabilidad: un SGBDR puede gestionar desde pequeñas bases de datos locales hasta sistemas con grandes volúmenes de información.
- Concurrencia: varios clientes pueden acceder a la misma información, siempre que el gestor controle bloqueos, transacciones y aislamiento.
Un ejemplo sencillo de relación entre tablas sería el siguiente:
CREATE TABLE usuarios (
id INT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(150) UNIQUE NOT NULL
);
CREATE TABLE pedidos (
id INT PRIMARY KEY,
usuario_id INT NOT NULL,
fecha DATE NOT NULL,
FOREIGN KEY (usuario_id) REFERENCES usuarios(id)
);
La tabla usuarios guarda los datos de cada persona usuaria. La tabla pedidos guarda pedidos y utiliza usuario_id como clave foránea para indicar a qué usuario pertenece cada pedido. Gracias a esa clave foránea, el SGBDR puede impedir que se registre un pedido asociado a un usuario inexistente.
Lectura del ejemplo
La clave primaria identifica cada fila dentro de su tabla. La clave foránea crea una relación controlada entre tablas. Esta relación no es solo descriptiva: el SGBDR la puede comprobar y rechazar operaciones que rompan la integridad.
El siguiente esquema resume el recorrido habitual entre una aplicación y una base de datos:
flowchart LR
A[Aplicación Kotlin] --> B[Capa de acceso a datos]
B --> C[JDBC / ORM / JPA / Spring Data]
C --> D[Driver JDBC]
D --> E[SGBDR]
E --> F[(Base de datos relacional)]
A continuación, se muestra un esquema más detallado del flujo simplificado de una operación de acceso a datos desde Kotlin.
flowchart TD
Cliente[Interfaz o servicio] --> Operacion[Operación de negocio]
Operacion --> DAO[DAO o repositorio]
DAO --> SQL[Sentencia SQL]
SQL --> BBDD[(Base de datos)]
BBDD --> Resultado[ResultSet o filas afectadas]
Resultado --> Objeto[Objetos Kotlin]
El recorrido anterior ayuda a separar responsabilidades. La interfaz o el servicio no deberían construir todo el SQL de cualquier manera. Lo habitual es que deleguen en una capa de acceso a datos, que prepara la consulta, la ejecuta, interpreta el resultado y devuelve objetos útiles para el resto de la aplicación.
4. Kotlin y acceso a bases de datos¶
Kotlin se ejecuta sobre la JVM, por lo que puede utilizar directamente las bibliotecas del ecosistema Java. Esto permite trabajar con JDBC, que es la API estándar de Java para conectarse a bases de datos relacionales.
En la práctica, Kotlin aporta una sintaxis más concisa, null-safety y facilidad para modelar datos con data class, pero el acceso de bajo nivel se realiza mediante las clases de java.sql, como Connection, PreparedStatement, ResultSet o SQLException.
Un modelo sencillo para representar una fila de una tabla de usuarios podría ser:
Esta clase no es una tabla por sí misma. Es una representación en Kotlin de los datos que queremos insertar, recuperar o mostrar.
Conviene distinguir bien estos dos mundos:
- En la base de datos, una fila de
usuarioscontiene valores almacenados. - En Kotlin, un objeto
Usuariocontiene valores ya cargados en memoria. - El acceso a datos se encarga de transformar filas en objetos y objetos en filas.
Por ejemplo, cuando una consulta devuelve una fila con id = 1, nombre = "Ana" y email = "ana@example.com", el programa puede construir este objeto:
El objeto resultante ya puede usarse en la lógica de la aplicación, mostrarse en una interfaz o devolverse desde una API. Lo importante es no confundir la clase Usuario con la tabla usuarios: se parecen porque representan la misma información, pero pertenecen a capas distintas.
5. Métodos de acceso a bases de datos relacionales¶
Existen varias formas de acceder a una base de datos desde una aplicación. No todas tienen el mismo nivel de abstracción ni sirven igual para todos los proyectos.
5.1. JDBC¶
JDBC significa Java Database Connectivity. Es una API estándar que permite abrir conexiones, enviar sentencias SQL y recibir resultados desde una base de datos relacional.
Ventajas principales:
- Es estándar y está soportada por la mayoría de SGBDR.
- Permite controlar con detalle las consultas, los parámetros y las transacciones.
- Ayuda a entender qué ocurre realmente entre la aplicación y la base de datos.
Desventajas principales:
- Requiere escribir bastante código repetitivo.
- Es fácil cometer errores si no se cierran bien los recursos.
- No ofrece por sí misma una abstracción orientada a objetos.
JDBC es especialmente útil para aprender los fundamentos y para casos donde se necesita control fino sobre SQL.
5.2. ORM¶
Un ORM (Object-Relational Mapping) es una herramienta que relaciona objetos de la aplicación con tablas de la base de datos. En lugar de escribir todo el SQL manualmente, se trabaja con clases, objetos y métodos.
Ejemplos conocidos son Hibernate en Java o Exposed en Kotlin.
Ventajas principales:
- Reduce código repetitivo de acceso a datos.
- Permite trabajar con una visión más orientada a objetos.
- Centraliza parte del mapeo entre clases y tablas.
Desventajas principales:
- Puede generar consultas SQL poco eficientes si no se usa con criterio.
- Añade una capa de complejidad y una curva de aprendizaje.
- Puede ocultar detalles importantes del acceso real a la base de datos.
5.3. JPA¶
JPA (Java Persistence API) es una especificación estándar de Java para gestionar persistencia. No es una implementación concreta: define cómo debe comportarse una API de persistencia. Hibernate, por ejemplo, puede actuar como implementación de JPA.
Ventajas principales:
- Ofrece una forma estándar de trabajar con persistencia en Java/JVM.
- Abstrae diferencias entre distintos gestores de bases de datos.
- Facilita el trabajo con entidades y relaciones.
Desventajas principales:
- Puede ser más compleja que JDBC para proyectos pequeños.
- Puede ocultar el SQL real que se ejecuta.
- Cuando se necesita control muy granular, JDBC puede resultar más directo.
5.4. Spring Data¶
Spring Data es un proyecto del ecosistema Spring que proporciona abstracciones de acceso a datos. Permite crear repositorios y reducir mucho el código necesario para operaciones habituales.
Ventajas principales:
- Acelera el desarrollo de aplicaciones empresariales.
- Integra JDBC, JPA y otras tecnologías de persistencia.
- Encaja bien en arquitecturas basadas en Spring Boot.
Desventajas principales:
- Requiere conocer el ecosistema Spring.
- Puede ser excesivo para ejemplos pequeños o aplicaciones simples.
- La configuración inicial puede ser más compleja que una conexión JDBC directa.
Cómo elegir
Para aprender, conviene empezar con JDBC porque muestra el mecanismo real de conexión, consulta y resultado. Para proyectos grandes, ORM, JPA o Spring Data pueden reducir código repetitivo, pero no sustituyen la necesidad de entender SQL y el funcionamiento de la base de datos.
La siguiente tabla resume una decisión práctica:
| Situación | Opción razonable | Motivo |
|---|---|---|
| Aprender cómo se conecta y ejecuta SQL | JDBC | Muestra el flujo real de bajo nivel. |
| Proyecto pequeño con pocas consultas | JDBC | Evita añadir capas innecesarias. |
| Dominio con muchas entidades relacionadas | ORM/JPA | Reduce mapeo repetitivo entre objetos y tablas. |
| Aplicación Spring Boot empresarial | Spring Data | Integra repositorios, transacciones y configuración. |
| Consulta SQL muy específica y optimizada | JDBC o SQL nativo | Permite controlar exactamente la consulta. |
No hay una respuesta universal. Elegir una tecnología de acceso a datos depende del tamaño del proyecto, del equipo, del rendimiento esperado, del conocimiento previo y del nivel de control que se necesite sobre SQL.
6. Trabajar con bases de datos mediante JDBC¶
Cuando una aplicación trabaja con JDBC suele repetir este flujo:
- Obtener una conexión.
- Preparar una sentencia SQL.
- Asignar parámetros si la sentencia los necesita.
- Ejecutar la sentencia.
- Leer resultados o comprobar filas afectadas.
- Cerrar recursos.
- Tratar errores.
6.1. Conectarse a una base de datos¶
Para conectarse a una base de datos se necesita:
- La URL JDBC, que indica el tipo de base de datos, servidor, puerto y nombre de la base de datos.
- El usuario con permisos suficientes.
- La contraseña.
- El driver JDBC correspondiente al SGBDR que se utilice.
- Otras opciones de configuración, como SSL, zona horaria o codificación, según el gestor.
Una URL JDBC para MySQL puede tener esta forma:
Un ejemplo básico de conexión sería:
import java.sql.DriverManager
import java.sql.SQLException
fun main() {
val url = "jdbc:mysql://localhost:3306/mydatabase"
val usuario = "usuario"
val password = "contraseña"
try {
DriverManager.getConnection(url, usuario, password).use { conexion ->
println("Conexión correcta: ${conexion.isValid(2)}")
}
} catch (e: SQLException) {
println("Error al conectar con la base de datos: ${e.message}")
}
}
Lectura del ejemplo:
DriverManager.getConnection(...)intenta abrir una conexión con el SGBDR.- La URL indica el gestor, el servidor, el puerto y la base de datos.
usuarioypasswordse usan para autenticar la conexión.use { ... }cierra la conexión automáticamente al salir del bloque.conexion.isValid(2)comprueba si la conexión responde en un máximo de 2 segundos.- El bloque
catchcaptura errores de conexión comunicados medianteSQLException.
En versiones actuales de JDBC, si el driver está en el classpath, normalmente no hace falta llamar explícitamente a Class.forName("com.mysql.cj.jdbc.Driver"). Aun así, puede aparecer en ejemplos antiguos o cuando se quiere forzar la carga del controlador.
Busca el ejemplo ConexionValida y estudialo/ejecutalo.
Conexiones y rendimiento
Abrir una conexión es una operación costosa. En aplicaciones reales no se suele abrir y cerrar una conexión nueva para cada petición sin control. Se utilizan pools de conexiones, como HikariCP, para reutilizar conexiones de forma eficiente.
6.2. Almacenar información¶
Almacenar información suele corresponder a una sentencia INSERT. Si trabajamos con orientación a objetos, lo habitual es recibir un objeto y guardar sus propiedades en una tabla.
El proceso general es:
- Crear una clase que represente los datos que se van a guardar.
- Crear una instancia con valores válidos.
- Abrir una conexión.
- Preparar una sentencia
INSERT. - Asignar parámetros mediante
PreparedStatement. - Ejecutar la sentencia con
executeUpdate(). - Comprobar cuántas filas se han insertado.
import java.sql.Connection
data class UsuarioNuevo(val nombre: String, val email: String)
fun insertarUsuario(connection: Connection, usuario: UsuarioNuevo): Int {
val sql = "INSERT INTO usuarios (nombre, email) VALUES (?, ?)"
connection.prepareStatement(sql).use { statement ->
statement.setString(1, usuario.nombre)
statement.setString(2, usuario.email)
return statement.executeUpdate()
}
}
El uso de PreparedStatement evita concatenar valores directamente en la cadena SQL. Esto mejora la claridad, reduce errores de tipos y ayuda a prevenir inyección SQL.
Lectura del ejemplo:
UsuarioNuevorepresenta los datos que se van a insertar.- La sentencia SQL usa
?como marcadores de posición. setString(1, usuario.nombre)asigna el primer parámetro.setString(2, usuario.email)asigna el segundo parámetro.executeUpdate()ejecuta elINSERTy devuelve las filas insertadas.- La función devuelve ese número para que quien la llame pueda comprobarlo.
Un uso mínimo podría ser:
val nuevoUsuario = UsuarioNuevo("Ana", "ana@example.com")
val filas = insertarUsuario(connection, nuevoUsuario)
if (filas == 1) {
println("Usuario insertado correctamente.")
}
En una práctica de clase, este tipo de comprobación es importante porque permite distinguir entre "la sentencia se ha ejecutado" y "la operación ha producido el efecto esperado".
Busca el ejemplo InsertBasico y estudialo/ejecutalo.
6.3. Recuperar y mostrar información¶
Para recuperar información se utiliza una consulta SELECT. JDBC devuelve un ResultSet, que se recorre fila a fila.
import java.sql.Connection
data class Usuario(val id: Int, val nombre: String, val email: String)
fun obtenerUsuarios(connection: Connection): List<Usuario> {
val sql = "SELECT id, nombre, email FROM usuarios ORDER BY nombre"
val usuarios = mutableListOf<Usuario>()
connection.prepareStatement(sql).use { statement ->
statement.executeQuery().use { resultSet ->
while (resultSet.next()) {
usuarios.add(
Usuario(
id = resultSet.getInt("id"),
nombre = resultSet.getString("nombre"),
email = resultSet.getString("email")
)
)
}
}
}
return usuarios
}
Lectura del ejemplo:
SELECT id, nombre, emailindica exactamente qué columnas se recuperan.ORDER BY nombreordena los resultados antes de devolverlos.executeQuery()se usa porque la consulta devuelve un conjunto de resultados.resultSet.next()avanza fila a fila.getIntygetStringextraen valores tipados de cada columna.- Cada fila se transforma en un objeto
Usuario. - La lista
usuarioses el resultado ya preparado para la aplicación.
Una vez recuperados los datos, la aplicación puede mostrarlos de varias formas:
- En consola, durante pruebas o ejercicios iniciales.
- En una tabla de una interfaz gráfica o web.
- En una lista si se muestran pocos datos.
- En un gráfico si se trata de datos numéricos agregados.
- En una API, devolviendo JSON a otra aplicación.
Privacidad y salida de datos
Mostrar información no es un paso neutro. Hay que decidir qué datos puede ver cada persona usuaria, evitar mostrar información sensible y cumplir las normas de privacidad aplicables.
Por ejemplo, en consola podríamos mostrar solo los campos necesarios:
val usuarios = obtenerUsuarios(connection)
usuarios.forEach { usuario ->
println("${usuario.id} - ${usuario.nombre} <${usuario.email}>")
}
Si esa información se muestra en una aplicación web, probablemente no se imprimirá con println, sino que se convertirá a una vista HTML o a una respuesta JSON. La operación de lectura es la misma; lo que cambia es la capa de presentación.
Busca los ejemplos SelectBasico, MapeoFilaAObjeto y MapeoUnoAMuchos y estudialos/ejecutalos.
6.4. Eliminar registros¶
Eliminar información corresponde a una sentencia DELETE. El punto crítico es la cláusula WHERE: si se omite o se escribe mal, se pueden borrar más registros de los esperados.
import java.sql.Connection
fun eliminarUsuario(connection: Connection, id: Int): Int {
val sql = "DELETE FROM usuarios WHERE id = ?"
connection.prepareStatement(sql).use { statement ->
statement.setInt(1, id)
return statement.executeUpdate()
}
}
executeUpdate() devuelve el número de filas afectadas. Si devuelve 1, normalmente se ha eliminado el registro esperado. Si devuelve 0, no había ningún usuario con ese identificador.
Lectura del ejemplo:
- La consulta contiene
WHERE id = ?para borrar solo un usuario. - El identificador se asigna con
statement.setInt(1, id). executeUpdate()ejecuta el borrado.- El valor devuelto permite saber si se ha borrado alguna fila.
Un uso seguro debería comprobar el resultado:
val filasEliminadas = eliminarUsuario(connection, 7)
if (filasEliminadas == 1) {
println("Usuario eliminado.")
} else {
println("No existe ningún usuario con ese identificador.")
}
Cuidado con los borrados
Una sentencia como DELETE FROM usuarios sin WHERE elimina todos los registros de la tabla. En aplicaciones reales conviene validar muy bien estas operaciones y, si procede, ejecutarlas dentro de una transacción.
Busca el ejemplo DeleteBasico y estudialo/ejecutalo.
6.5. Actualizar registros¶
Actualizar información corresponde a una sentencia UPDATE. Igual que ocurre con DELETE, la cláusula WHERE es esencial para modificar solo los registros previstos.
import java.sql.Connection
fun actualizarEmail(connection: Connection, id: Int, nuevoEmail: String): Int {
val sql = "UPDATE usuarios SET email = ? WHERE id = ?"
connection.prepareStatement(sql).use { statement ->
statement.setString(1, nuevoEmail)
statement.setInt(2, id)
return statement.executeUpdate()
}
}
En un programa real no basta con ejecutar la actualización. También se debe comprobar el número de filas afectadas y decidir qué hacer si no se actualiza ninguna.
Lectura del ejemplo:
UPDATE usuarios SET email = ?indica qué campo se modifica.WHERE id = ?limita la modificación a un registro concreto.- El nuevo correo se asigna como primer parámetro.
- El identificador del usuario se asigna como segundo parámetro.
executeUpdate()devuelve cuántas filas se han actualizado.
val filasActualizadas = actualizarEmail(connection, 1, "juan.perez@example.com")
if (filasActualizadas == 1) {
println("El usuario se ha actualizado correctamente.")
} else {
println("No se ha encontrado ningún usuario con ese id.")
}
Si el resultado es 0, no significa necesariamente que haya fallado la conexión. Puede significar simplemente que no existe ningún usuario con id = 1. Esta diferencia es importante: no todos los problemas son excepciones técnicas.
Busca el ejemplo UpdateBasico y estudialo/ejecutalo.
6.6. Consultas complejas¶
Las consultas no siempre se limitan a recuperar todos los registros de una tabla. SQL permite filtrar, ordenar, agrupar y calcular resultados.
Por ejemplo, si tenemos una tabla ventas con las columnas id, fecha, monto, tipo y sucursal, podríamos calcular el total de ventas con tarjeta en una sucursal concreta durante enero:
import java.sql.Connection
fun totalVentasTarjetaEnero(connection: Connection, sucursal: String): Double {
val sql = """
SELECT SUM(monto) AS total
FROM ventas
WHERE sucursal = ?
AND tipo = ?
AND MONTH(fecha) = ?
""".trimIndent()
connection.prepareStatement(sql).use { statement ->
statement.setString(1, sucursal)
statement.setString(2, "tarjeta")
statement.setInt(3, 1)
statement.executeQuery().use { resultSet ->
return if (resultSet.next()) resultSet.getDouble("total") else 0.0
}
}
}
En esta consulta aparecen tres ideas importantes:
WHEREfiltra los registros.ANDcombina varias condiciones.SUMagrega valores para obtener un total.
Lectura del ejemplo:
- La consulta calcula un único dato: el total de ventas.
SUM(monto) AS totalasigna un nombre al resultado agregado.sucursal = ?,tipo = ?yMONTH(fecha) = ?son filtros parametrizados.setStringysetIntasignan valores sin concatenar texto en la consulta.resultSet.next()comprueba si hay una fila con el resultado.getDouble("total")recupera el total calculado por la base de datos.
Este tipo de consulta es útil cuando no interesa traer todas las ventas a Kotlin para sumarlas una a una. Es más eficiente pedirle al SGBDR que filtre y agregue, porque está diseñado precisamente para ese tipo de operaciones.
7. Crear aplicaciones de gestión de información¶
Una aplicación de gestión de información debe permitir que la persona usuaria realice operaciones sobre los datos de forma clara y segura. El caso más habitual es un CRUD, acrónimo de:
- Create: crear registros.
- Read: leer o consultar registros.
- Update: actualizar registros existentes.
- Delete: eliminar registros.
Un ejemplo típico sería una aplicación de inventario. La interfaz permitiría crear productos, consultar existencias, modificar precios, eliminar productos descatalogados y buscar productos por nombre, categoría o precio. Por debajo, cada acción de la interfaz se traduce en operaciones SQL ejecutadas desde la capa de acceso a datos.
7.1. Ejemplo de CRUD básico¶
El siguiente ejemplo reúne las operaciones principales sobre una tabla users. Está pensado como punto de partida didáctico; en una aplicación real separaríamos responsabilidades en clases, servicios y repositorios.
Antes de escribir el código Kotlin, asumimos una tabla sencilla:
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(150) UNIQUE NOT NULL
);
La tabla tiene un identificador autogenerado, un nombre obligatorio y un correo único. Esto permite probar inserciones correctas, errores por correo duplicado, consultas, actualizaciones y borrados.
import java.sql.Connection
import java.sql.DriverManager
import java.sql.SQLException
const val DB_URL = "jdbc:mysql://localhost:3306/mydatabase"
const val DB_USER = "root"
const val DB_PASSWORD = "mypassword"
data class User(val id: Int, val name: String, val email: String)
fun getConnection(): Connection =
DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD)
fun createRecord(name: String, email: String): Int =
getConnection().use { connection ->
val sql = "INSERT INTO users (name, email) VALUES (?, ?)"
connection.prepareStatement(sql).use { statement ->
statement.setString(1, name)
statement.setString(2, email)
statement.executeUpdate()
}
}
fun readAllRecords(): List<User> =
getConnection().use { connection ->
val sql = "SELECT id, name, email FROM users ORDER BY id"
val users = mutableListOf<User>()
connection.prepareStatement(sql).use { statement ->
statement.executeQuery().use { resultSet ->
while (resultSet.next()) {
users.add(
User(
id = resultSet.getInt("id"),
name = resultSet.getString("name"),
email = resultSet.getString("email")
)
)
}
}
}
users
}
fun updateRecord(id: Int, name: String, email: String): Int =
getConnection().use { connection ->
val sql = "UPDATE users SET name = ?, email = ? WHERE id = ?"
connection.prepareStatement(sql).use { statement ->
statement.setString(1, name)
statement.setString(2, email)
statement.setInt(3, id)
statement.executeUpdate()
}
}
fun deleteRecord(id: Int): Int =
getConnection().use { connection ->
val sql = "DELETE FROM users WHERE id = ?"
connection.prepareStatement(sql).use { statement ->
statement.setInt(1, id)
statement.executeUpdate()
}
}
fun main() {
try {
createRecord("Juan", "juan@example.com")
readAllRecords().forEach { println(it) }
val updatedRows = updateRecord(1, "Juan Pérez", "juan.perez@example.com")
println("Filas actualizadas: $updatedRows")
val deletedRows = deleteRecord(1)
println("Filas eliminadas: $deletedRows")
} catch (e: SQLException) {
println("Error de base de datos: ${e.message}")
}
}
Este ejemplo conserva la idea principal del CRUD:
getConnection()obtiene la conexión.createRecord()inserta un registro.readAllRecords()recupera todos los usuarios.updateRecord()modifica un registro existente.deleteRecord()elimina un registro por identificador.main()prueba el flujo completo.
Lectura detallada del ejemplo:
- Las constantes
DB_URL,DB_USERyDB_PASSWORDconcentran los datos de conexión. getConnection()abre una conexión nueva medianteDriverManager.- Cada función usa
getConnection().use { ... }para cerrar la conexión al terminar. createRecord()usaINSERTy devuelve las filas insertadas.readAllRecords()usaSELECTy transforma cada fila en un objetoUser.updateRecord()usaUPDATEconWHERE id = ?para modificar una sola fila.deleteRecord()usaDELETEconWHERE id = ?para borrar una sola fila.main()ejecuta una secuencia básica para comprobar que las operaciones funcionan.
Credenciales en ejemplos
En clase es aceptable ver constantes para simplificar el ejemplo, pero en una aplicación real las credenciales no deberían quedar escritas directamente en el código fuente. Lo habitual es usar variables de entorno, ficheros de configuración protegidos o gestores de secretos.
7.2. Mejorar el CRUD paso a paso¶
El ejemplo anterior es útil para aprender, pero todavía puede mejorarse. Una primera mejora consiste en comprobar siempre el resultado de las operaciones de escritura:
val filasInsertadas = createRecord("Ana", "ana@example.com")
if (filasInsertadas == 1) {
println("Inserción correcta.")
} else {
println("No se ha insertado ningún registro.")
}
Otra mejora consiste en evitar que la interfaz de usuario decida directamente qué SQL se ejecuta. Por ejemplo, un botón "Eliminar" no debería construir una sentencia SQL. Debería llamar a una función de acceso a datos, y esa función debería encargarse de preparar la consulta, asignar parámetros, ejecutar y comprobar el resultado.
De ejemplo a aplicación
Primero aprendemos el CRUD con funciones sencillas. Después lo refactorizamos hacia una capa de acceso a datos para que la aplicación sea más mantenible, más fácil de probar y menos dependiente de detalles concretos de JDBC.
8. Manejo de errores¶
Al trabajar con JDBC es importante tener en cuenta que pueden ocurrir excepciones en casi cualquier fase del proceso: al establecer la conexión, al preparar una sentencia, al asignar parámetros, al ejecutar una consulta, al recorrer un ResultSet o al cerrar recursos. Por tanto, el manejo de errores no debe añadirse al final "si sobra tiempo"; forma parte del diseño de la capa de acceso a datos.
En una aplicación real, los errores pueden tener causas muy distintas:
- Al conectar con el servidor.
- Al autenticar usuario y contraseña.
- Al ejecutar una sentencia SQL mal escrita.
- Al insertar datos que incumplen restricciones.
- Al modificar o eliminar registros inexistentes.
- Al perder la conexión durante una operación.
JDBC comunica la mayoría de estos problemas mediante SQLException. Esta excepción puede incluir un mensaje, un código de error propio del SGBDR y un estado SQL. En la práctica, no todos los gestores informan exactamente igual, por lo que conviene consultar la documentación del motor que se esté usando.
You should explicitly close
Statements,ResultSets, andConnectionswhen you no longer need them.
En Kotlin, una forma sencilla de cerrar recursos es usar use, que funciona de forma similar a try-with-resources en Java. Este enfoque evita tener que cerrar manualmente Connection, PreparedStatement o ResultSet en muchos casos.
Idea práctica
Una operación de base de datos bien planteada no solo ejecuta SQL. También informa de errores comprensibles, registra información útil para depurar y libera los recursos aunque algo falle.
8.1. Errores de conexión¶
Los errores de conexión aparecen antes de ejecutar cualquier consulta. Pueden deberse a un servidor apagado, una URL JDBC incorrecta, un puerto cerrado, credenciales inválidas, ausencia del driver JDBC o problemas de red.
El objetivo de este bloque no es ocultar el error, sino convertirlo en una información útil. La persona usuaria necesita un mensaje comprensible; el equipo técnico necesita conservar la causa real para poder diagnosticar.
import java.sql.Connection
import java.sql.DriverManager
import java.sql.SQLException
fun abrirConexion(url: String, user: String, password: String): Connection {
try {
return DriverManager.getConnection(url, user, password)
} catch (e: SQLException) {
throw SQLException("No se ha podido conectar con la base de datos: ${e.message}", e)
}
}
No cierres antes de devolver
Una función que devuelve una Connection no debe cerrarla en un bloque finally antes de devolverla. Si la cierra, la persona que llama recibe una conexión inutilizable. El cierre debe hacerse después de terminar la operación, normalmente con use.
El uso correcto sería cerrar la conexión en el punto donde se utiliza:
try {
abrirConexion(url, user, password).use { connection ->
println("Conexión válida: ${connection.isValid(2)}")
}
} catch (e: SQLException) {
println("No se ha podido abrir la conexión.")
println("Detalle técnico: ${e.message}")
}
En este ejemplo, use garantiza que la conexión se cierre al terminar el bloque, tanto si la validación funciona como si se produce una excepción dentro del bloque.
8.2. Errores de inserción¶
Es importante manejar los errores de inserción para mantener la integridad de la información. Al insertar datos pueden fallar restricciones de clave primaria, restricciones de unicidad, campos obligatorios, claves foráneas o tipos de datos incompatibles.
Por ejemplo, si una tabla exige que el correo electrónico sea único y la aplicación intenta insertar un correo ya registrado, el SGBDR rechazará la operación. En MySQL, este caso suele devolver el código de error 1062, aunque el código exacto depende del gestor.
try {
val sql = "INSERT INTO customers (name, email) VALUES (?, ?)"
connection.prepareStatement(sql).use { statement ->
statement.setString(1, "John Doe")
statement.setString(2, "johndoe@email.com")
val filasInsertadas = statement.executeUpdate()
println("Filas insertadas: $filasInsertadas")
}
} catch (e: SQLException) {
when (e.errorCode) {
1062 -> {
println("Error: el correo electrónico ya está registrado.")
}
else -> {
println("Error al insertar datos: ${e.message}")
}
}
}
En este ejemplo se usa PreparedStatement para evitar concatenar valores directamente en la consulta. Si la inserción falla, el bloque catch comprueba el código de error y permite dar una respuesta más concreta. Si el código no es uno de los previstos, se muestra un mensaje genérico.
Códigos de error
Los códigos de error no son universales. El mismo problema puede comunicarse de forma distinta en MySQL, PostgreSQL, H2 u Oracle. Por eso conviene consultar la documentación del SGBDR utilizado y no basar toda la lógica de negocio en códigos específicos sin control.
8.3. Errores de eliminación¶
Para manejar errores de eliminación se pueden utilizar las mismas técnicas que en la inserción: try-catch, PreparedStatement, cierre correcto de recursos y comprobación del resultado. La diferencia importante es que eliminar un registro inexistente no siempre lanza una excepción. Muchas veces la operación se ejecuta correctamente, pero executeUpdate() devuelve 0.
Por tanto, comprobar el número de filas afectadas forma parte del manejo de errores funcionales. No es un error técnico de SQL, pero sí una situación que la aplicación debe tratar.
try {
val sql = "DELETE FROM usuarios WHERE id = ?"
connection.prepareStatement(sql).use { statement ->
statement.setInt(1, 1)
val filasEliminadas = statement.executeUpdate()
if (filasEliminadas > 0) {
println("El usuario se ha eliminado correctamente.")
} else {
println("No se ha eliminado ningún usuario.")
}
}
} catch (e: SQLException) {
println("Se ha producido un error al intentar eliminar el usuario.")
println("Mensaje de error: ${e.message}")
}
En este ejemplo, la sentencia DELETE solo elimina el registro cuyo id coincide con el parámetro. Si no existe ningún registro con ese identificador, no se borra nada y el programa informa de ello. Si se produce un problema real de base de datos, como falta de permisos o pérdida de conexión, se captura la excepción.
Borrados sin condición
La cláusula WHERE es obligatoria en este tipo de operaciones. Una sentencia DELETE FROM usuarios sin condición eliminaría todos los registros de la tabla.
8.4. Errores de actualización¶
Al igual que en la eliminación, actualizar registros exige controlar tanto errores técnicos como resultados funcionales. Algunos errores habituales son la falta de permisos, el incumplimiento de restricciones de integridad referencial, validaciones de la base de datos, datos con tipos incorrectos o pérdida de conexión.
También puede ocurrir que la consulta sea correcta, pero no actualice ninguna fila porque el identificador no existe. Ese caso debe comunicarse de forma clara.
try {
val sql = "UPDATE usuarios SET email = ? WHERE id = ?"
connection.prepareStatement(sql).use { statement ->
statement.setString(1, "nuevo.email@example.com")
statement.setInt(2, 1)
val filasActualizadas = statement.executeUpdate()
if (filasActualizadas == 1) {
println("El usuario se ha actualizado correctamente.")
} else {
println("No se ha encontrado ningún usuario con ese id.")
}
}
} catch (e: SQLException) {
println("Se ha producido un error al actualizar el usuario.")
println("Mensaje de error: ${e.message}")
}
Este patrón es muy parecido al de eliminación: se prepara una sentencia segura, se asignan parámetros, se ejecuta con executeUpdate() y se comprueba el número de filas afectadas. La diferencia es la acción realizada: aquí se modifica información existente.
8.5. Qué información mostrar y qué información registrar¶
Dentro del bloque catch, se puede proporcionar información al usuario o registrar el error en un archivo de log para su análisis posterior. Son objetivos distintos:
- Mensaje para la persona usuaria: debe ser claro, breve y seguro.
- Mensaje técnico o log: puede incluir detalles útiles para depurar.
- Respuesta de la aplicación: debe dejar el sistema en un estado coherente.
Por ejemplo, ante un fallo de inserción por correo duplicado, la persona usuaria puede ver "El correo ya está registrado". En cambio, el log técnico puede conservar el código de error, la operación que se estaba ejecutando y el momento del fallo.
No mostrar información sensible
No conviene mostrar al usuario final trazas completas, nombres internos de tablas, cadenas de conexión, usuarios de base de datos o detalles de infraestructura. Esa información puede ser útil para depurar, pero también puede exponer datos sensibles.
8.6. Enseñanza clave del manejo de errores¶
El manejo de errores en JDBC debe combinar tres ideas: anticipar fallos previsibles, liberar siempre los recursos y comprobar el resultado real de cada operación. En clase, lo importante es entender que una sentencia SQL puede estar bien escrita y aun así no producir el efecto esperado.
La idea clave es que una aplicación robusta no solo contempla el camino correcto. También define qué hacer cuando la conexión falla, cuando un dato incumple una restricción, cuando no se elimina ninguna fila o cuando una actualización no encuentra el registro esperado.
9. Patrón DAO¶
A medida que una aplicación crece, no conviene mezclar SQL, lógica de negocio e interfaz en el mismo archivo. Para separar responsabilidades se puede utilizar el patrón DAO (Data Access Object).
El objetivo del patrón DAO es aislar la lógica de acceso a datos. Así, el resto de la aplicación no necesita saber si se usa H2, MySQL, PostgreSQL, JDBC puro o una librería concreta.
Sin DAO, es habitual acabar con código SQL repartido por botones, menús, controladores o servicios. Eso hace que cualquier cambio en la base de datos obligue a revisar muchas partes de la aplicación. Con DAO, el SQL queda concentrado en una capa concreta.
9.1. Estructura general¶
Una arquitectura sencilla con DAO puede tener estas piezas:
UserEntity: clase que representa los datos de un usuario.UserDAO: interfaz con operaciones de acceso a datos, comocreate,getAll,getById,updateydelete.UserDAOH2: implementación concreta del DAO para una base de datos H2.UserService: interfaz con operaciones de negocio.UserServiceImpl: servicio que usa el DAO para realizar operaciones.DataSourceFactory: factoría que proporciona objetosDataSourcesegún el origen de datos.main: punto de prueba donde se crea la base de datos, el DAO y el servicio.
Una interfaz DAO mínima podría tener esta forma:
interface UserDAO {
fun create(name: String, email: String): Int
fun getAll(): List<User>
fun getById(id: Int): User?
fun update(id: Int, name: String, email: String): Int
fun delete(id: Int): Int
}
Lectura del ejemplo:
createinserta un usuario y devuelve cuántas filas se han creado.getAllrecupera todos los usuarios como una lista de objetos.getByIddevuelve un usuario onullsi no existe.updatemodifica un usuario existente y devuelve filas afectadas.deleteelimina por identificador y también devuelve filas afectadas.
La ventaja es que el resto de la aplicación puede depender de UserDAO sin conocer el SQL interno. Una implementación concreta, por ejemplo UserDAOJDBC, se encargaría de usar Connection, PreparedStatement y ResultSet.
class UserService(private val userDAO: UserDAO) {
fun registrarUsuario(name: String, email: String) {
val filas = userDAO.create(name, email)
if (filas != 1) {
error("No se ha podido registrar el usuario.")
}
}
}
En este ejemplo, el servicio no sabe cómo se inserta el usuario en la base de datos. Solo sabe que existe una operación create. Esta separación permite cambiar la implementación del DAO sin reescribir toda la lógica de negocio.
classDiagram
class UserEntity {
+UUID id
+String name
+String email
}
class UserDAO {
<<interface>>
+create(user)
+getAll()
+getById(id)
+update(user)
+delete(id)
}
class UserDAOH2 {
-DataSource dataSource
+create(user)
+getAll()
+getById(id)
+update(user)
+delete(id)
}
class UserService {
<<interface>>
+create(user)
+getAll()
+getById(id)
+update(user)
+delete(id)
}
class UserServiceImpl {
-UserDAO dao
}
UserDAO <|.. UserDAOH2
UserService <|.. UserServiceImpl
UserServiceImpl --> UserDAO
UserDAOH2 --> UserEntity
El DAO no elimina la necesidad de conocer SQL, pero ayuda a que el código sea más mantenible. Si mañana se cambia H2 por PostgreSQL, la aplicación debería modificar la implementación del DAO, no toda la lógica de negocio.
Idea profesional
Separar la capa de acceso a datos evita que una aplicación acabe dependiendo de detalles concretos del SGBDR en todas partes. Esa separación facilita pruebas, mantenimiento y evolución del proyecto.
10. Ideas clave y buenas prácticas¶
- Las bases de datos permiten persistir información de forma estructurada, segura y compartida.
- Un SGBDR relacional organiza los datos en tablas y protege la integridad mediante claves y restricciones.
- JDBC es la base de acceso a datos en la JVM; ORM, JPA y Spring Data añaden capas de abstracción.
PreparedStatementdebe ser la opción habitual para consultas con parámetros.executeQuery()se usa para consultas que devuelven resultados;executeUpdate()se usa paraINSERT,UPDATEyDELETE.- El resultado de
executeUpdate()debe comprobarse para saber cuántas filas se han visto afectadas. - Las conexiones, sentencias y resultados deben cerrarse siempre.
- Las excepciones SQL deben tratarse de forma útil, sin ocultar el problema ni mostrar información sensible.
- El patrón DAO ayuda a separar la lógica de acceso a datos de la lógica de negocio.
11. Conclusión¶
Acceder a bases de datos desde Kotlin implica mucho más que escribir sentencias SQL. Una aplicación bien diseñada debe saber conectarse, ejecutar operaciones CRUD, transformar registros en objetos, comprobar resultados, manejar errores y proteger la integridad de la información.
La enseñanza principal de este tema es que el acceso a datos forma parte de la arquitectura de la aplicación. Cuanto más clara esté esa capa, más fácil será mantener, probar y evolucionar el software.
Fuentes y referencias¶
- Ejemplos U9
- Programación - 08 Programación con Bases de Datos - José Luis González
- Servicio de usuario usando patrón DAO
- Hibernate ORM
- Exposed - Kotlin SQL Framework
- HikariCP
- The DTO Pattern - Baeldung
- SQLDelight