Saltar a contenido

9.3.-Sentencias JDBC y ResultSet

9.3. Sentencias JDBC y ResultSet

Resumen

En este punto profundizamos en el código JDBC. Veremos cuándo usar Statement, por qué PreparedStatement debe ser la opción habitual con parámetros y cómo recorrer un ResultSet para transformar filas SQL en objetos Kotlin.

En el punto anterior abrimos una conexión. Ahora necesitamos usar esa conexión para enviar SQL a la base de datos y leer la respuesta.

Código Descripción
RA9 Gestiona información almacenada en bases de datos manteniendo la integridad y consistencia de los 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.

1. Tabla de trabajo

Para mantener los ejemplos sencillos usaremos una tabla clientes:

CREATE TABLE clientes (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    email VARCHAR(150) UNIQUE NOT NULL
);

Datos de prueba:

INSERT INTO clientes (nombre, email) VALUES
    ('Ana López', 'ana@example.com'),
    ('Juan Pérez', 'juan@example.com'),
    ('Marta Ruiz', 'marta@example.com');

Una consulta de comprobación:

SELECT id, nombre, email FROM clientes ORDER BY id;

Salida esperada:

 id | nombre      | email
----+-------------+-------------------
  1 | Ana López   | ana@example.com
  2 | Juan Pérez  | juan@example.com
  3 | Marta Ruiz  | marta@example.com

Para clase

Antes de escribir código Kotlin, conviene comprobar la tabla desde el cliente SQL del gestor. Así separamos dos problemas: si la tabla no funciona en SQL, el error no está en Kotlin.

Y el modelo Kotlin equivalente:

data class Cliente(
    val id: Int,
    val nombre: String,
    val email: String
)

Lectura del ejemplo:

  • La tabla vive en la base de datos.
  • Cliente vive en memoria durante la ejecución del programa.
  • El código JDBC debe transformar filas en objetos y objetos en parámetros SQL.

2. Statement: SQL fijo y sin datos externos

Statement permite ejecutar una cadena SQL completa. Puede ser válido para consultas fijas que no reciben datos externos.

import java.sql.Connection

fun contarClientes(connection: Connection): Int {
    val sql = "SELECT COUNT(*) AS total FROM clientes"

    connection.createStatement().use { statement ->
        statement.executeQuery(sql).use { resultSet ->
            return if (resultSet.next()) {
                resultSet.getInt("total")
            } else {
                0
            }
        }
    }
}

Lectura del ejemplo:

  • La consulta no recibe valores de la persona usuaria.
  • createStatement() crea una sentencia simple.
  • executeQuery(sql) se usa porque la consulta devuelve filas.
  • ResultSet contiene el resultado de la consulta.
  • resultSet.next() mueve el cursor a la primera fila.

Busca el ejemplo StatementSoloLectura y estúdialo o ejecútalo.

Límite de Statement

Si la consulta necesita valores variables, no conviene construir SQL concatenando texto. En ese caso debe usarse PreparedStatement.

3. El problema de concatenar SQL

Un error habitual al empezar con JDBC es construir consultas pegando cadenas.

val email = "ana@example.com"
val sql = "SELECT id, nombre, email FROM clientes WHERE email = '$email'"

val statement = connection.createStatement()
val resultSet = statement.executeQuery(sql)

El código parece sencillo, pero mezcla la estructura de la consulta con un dato externo.

Riesgos:

  • La consulta puede romperse si el dato contiene comillas.
  • El dato puede modificar el significado del SQL.
  • Aumenta el riesgo de inyección SQL.
  • El código queda menos claro y más difícil de probar.

Riesgo de inyección SQL

Concatenar valores externos dentro del SQL puede permitir inyección SQL. Aunque el ejemplo parezca pequeño, en una aplicación real los datos pueden venir de un formulario, de una API o de otro sistema.

4. PreparedStatement: SQL con parámetros

PreparedStatement separa la estructura de la consulta de los valores que se le pasan. La consulta se escribe con marcadores ?, y después se asigna cada valor con métodos tipados.

import java.sql.Connection

fun buscarClientePorEmail(connection: Connection, email: String): Cliente? {
    val sql = """
        SELECT id, nombre, email
        FROM clientes
        WHERE email = ?
    """.trimIndent()

    connection.prepareStatement(sql).use { statement ->
        statement.setString(1, email)

        statement.executeQuery().use { resultSet ->
            return if (resultSet.next()) {
                Cliente(
                    id = resultSet.getInt("id"),
                    nombre = resultSet.getString("nombre"),
                    email = resultSet.getString("email")
                )
            } else {
                null
            }
        }
    }
}

Lectura del ejemplo:

  • ? indica que la consulta tiene un parámetro.
  • setString(1, email) asigna el primer parámetro.
  • Los índices de parámetros empiezan en 1, no en 0.
  • executeQuery() devuelve un ResultSet.
  • Si existe una fila, se transforma en Cliente.
  • Si no hay resultados, se devuelve null.

Busca el ejemplo PreparedSelectParametro y estúdialo o ejecútalo.

5. ResultSet: leer filas de una consulta

Un ResultSet representa el conjunto de filas devuelto por una consulta SELECT. Se recorre mediante un cursor.

fun obtenerClientes(connection: Connection): List<Cliente> {
    val sql = "SELECT id, nombre, email FROM clientes ORDER BY nombre"
    val clientes = mutableListOf<Cliente>()

    connection.prepareStatement(sql).use { statement ->
        statement.executeQuery().use { resultSet ->
            while (resultSet.next()) {
                clientes.add(
                    Cliente(
                        id = resultSet.getInt("id"),
                        nombre = resultSet.getString("nombre"),
                        email = resultSet.getString("email")
                    )
                )
            }
        }
    }

    return clientes
}

Lectura del ejemplo:

  • executeQuery() se usa cuando esperamos filas.
  • while (resultSet.next()) recorre todas las filas.
  • getInt y getString leen columnas tipadas.
  • Cada fila se convierte en un objeto Cliente.
  • La función devuelve una lista ya preparada para la aplicación.

Busca los ejemplos SelectBasico y MapeoFilaAObjeto y estúdialos o ejecútalos.

6. Consultas con filtros y agregaciones

Las consultas no siempre se limitan a recuperar todos los registros de una tabla. SQL permite filtrar, ordenar, agrupar y calcular resultados. Desde JDBC, la idea es la misma: preparar la consulta, asignar parámetros, ejecutar y leer el ResultSet.

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:

import java.sql.Connection

fun totalVentasTarjeta(connection: Connection, sucursal: String): Double {
    val sql = """
        SELECT SUM(monto) AS total
        FROM ventas
        WHERE sucursal = ?
          AND tipo = ?
    """.trimIndent()

    connection.prepareStatement(sql).use { statement ->
        statement.setString(1, sucursal)
        statement.setString(2, "tarjeta")

        statement.executeQuery().use { resultSet ->
            return if (resultSet.next()) {
                resultSet.getDouble("total")
            } else {
                0.0
            }
        }
    }
}

Lectura del ejemplo:

  • La consulta calcula un único dato: el total de ventas.
  • SUM(monto) AS total asigna un nombre al resultado agregado.
  • sucursal = ? y tipo = ? son filtros parametrizados.
  • setString asigna valores sin concatenar texto dentro del SQL.
  • 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. Operaciones que no devuelven filas

INSERT, UPDATE y DELETE no devuelven un ResultSet. Se ejecutan con executeUpdate(), que devuelve cuántas filas se han visto afectadas.

fun cambiarEmail(connection: Connection, id: Int, nuevoEmail: String): Int {
    val sql = "UPDATE clientes SET email = ? WHERE id = ?"

    connection.prepareStatement(sql).use { statement ->
        statement.setString(1, nuevoEmail)
        statement.setInt(2, id)
        return statement.executeUpdate()
    }
}

Lectura del ejemplo:

  • UPDATE clientes SET email = ? indica qué campo se modifica.
  • WHERE id = ? limita la modificación a una fila concreta.
  • executeUpdate() devuelve el número de filas actualizadas.
  • Si devuelve 0, puede significar que no existe ningún cliente con ese id.

Resultado funcional

Que una sentencia no lance excepción no significa que haya hecho lo esperado. En operaciones de escritura, comprobar las filas afectadas forma parte del diseño correcto.

8. Plan de ejecución y reutilización

Cuando un SGBDR recibe una consulta, analiza cómo ejecutarla. Ese análisis genera un plan de ejecución, es decir, una estrategia para obtener los datos.

Si se construyen consultas distintas para cada valor, el SGBDR puede tratarlas como sentencias diferentes:

SELECT * FROM clientes WHERE email = 'ana@example.com';
SELECT * FROM clientes WHERE email = 'juan@example.com';

Con PreparedStatement, la estructura se mantiene estable:

SELECT * FROM clientes WHERE email = ?;

Después cambian los valores, no la forma de la consulta:

statement.setString(1, "ana@example.com")
statement.setString(1, "juan@example.com")

El impacto real depende del gestor, del driver y de la configuración, pero la idea docente es clara: una consulta parametrizada expresa mejor la intención y evita reconstruir SQL manualmente.

9. Logs y consultas preparadas

Cuando se usa PreparedStatement, puede surgir una duda: si la consulta contiene ?, ¿cómo se registra en logs la consulta real?

val sql = "SELECT * FROM clientes WHERE email = ?"
logger.debug(sql)

Este log muestra la plantilla de la consulta, pero no el valor del parámetro. En ocasiones eso es suficiente, porque evita registrar datos personales o sensibles.

Algunos drivers permiten registrar la sentencia preparada con los parámetros ya aplicados:

val sql = "SELECT * FROM clientes WHERE email = ?"

connection.prepareStatement(sql).use { statement ->
    statement.setString(1, email)
    logger.debug(statement.toString())
}

En determinados drivers, la salida puede incluir algo parecido a:

SELECT * FROM clientes WHERE email = 'ana@example.com'

Cuidado con los logs

Registrar parámetros puede ayudar a depurar, pero también puede exponer información sensible. No conviene registrar contraseñas, tokens, datos personales innecesarios ni cadenas de conexión.

Busca el ejemplo GestionSQLException y estúdialo o ejecútalo.

10. Buenas prácticas

  • Usar PreparedStatement siempre que haya parámetros.
  • Reservar Statement para SQL fijo y controlado por el programa.
  • Evitar concatenar datos externos dentro del SQL.
  • Usar métodos tipados como setString, setInt o setDouble.
  • Comprobar el resultado de executeUpdate().
  • Cerrar Connection, PreparedStatement y ResultSet con use.
  • Registrar errores sin exponer información sensible.

Busca los ejemplos CierreRecursosUse y GestionSQLException y estúdialos o ejecútalos.

11. Cierre del punto

Este punto establece el bloque técnico central de JDBC:

  • Statement ejecuta SQL fijo.
  • PreparedStatement ejecuta SQL parametrizado.
  • ResultSet permite recorrer filas.
  • executeQuery() se usa para consultas de lectura.
  • executeUpdate() se usa para operaciones que modifican datos.

En el siguiente punto usaremos estas piezas para construir aplicaciones CRUD organizadas mediante una capa de acceso a datos.

Fuentes y referencias

Presentación