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');

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. 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.

7. 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.

8. 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.

9. 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