Saltar a contenido

9.3.-JDBC: Prepared statement

9.3. JDBC PreparedStatement

Resumen

En este tema se explica por qué PreparedStatement debe ser la opción habitual cuando una consulta SQL recibe valores externos. Su uso mejora la seguridad, reduce errores al construir SQL y permite que el SGBDR reutilice mejor los planes de ejecución.

En el punto anterior ya vimos cómo conectarnos a una base de datos y ejecutar consultas. Ahora nos centramos en una decisión concreta pero muy importante: cómo preparar consultas que necesitan parámetros.

Directamente relacionado con el RA9 del módulo de Programación, este tema trabaja la gestión de información en bases de datos manteniendo integridad y consistencia. En concreto, refuerza la escritura de código para consultar, almacenar, modificar y borrar información de forma segura.

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. El problema de construir SQL con texto

Un error habitual al empezar con JDBC es construir consultas concatenando cadenas. El código puede parecer sencillo, pero mezcla la estructura de la consulta con los datos que llegan desde la aplicación.

val nombre = "pepe"
val sql = "SELECT * FROM persona WHERE nombre = '$nombre'"

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

Lectura del ejemplo:

  • nombre representa un dato externo que podría venir de un formulario.
  • sql se construye pegando ese valor dentro de la consulta.
  • createStatement() crea una sentencia SQL normal.
  • executeQuery(sql) envía la consulta completa al SGBDR.

El problema es que la base de datos recibe una cadena final ya montada. Si el valor de nombre contiene comillas, operadores o fragmentos SQL, la consulta puede romperse o incluso cambiar de significado.

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 una persona usuaria, de una API o de otro sistema.

Busca el ejemplo PreparedSelectParametro y estudialo/ejecutalo.

2. Qué aporta PreparedStatement

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.

val sql = "SELECT * FROM persona WHERE nombre = ?"

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

    statement.executeQuery().use { resultSet ->
        while (resultSet.next()) {
            println(resultSet.getString("nombre"))
        }
    }
}

Lectura del ejemplo:

  • ? indica que la consulta tiene un parámetro.
  • setString(1, "pepe") asigna el primer parámetro de la consulta.
  • executeQuery() ejecuta una consulta de lectura.
  • resultSet.next() avanza por las filas devueltas.
  • getString("nombre") recupera el valor de la columna nombre.
  • use cierra tanto la sentencia como el ResultSet.

La diferencia fundamental es que el valor "pepe" no se pega al SQL. JDBC lo envía como parámetro, y el driver se encarga de tratarlo como dato, no como parte de la sintaxis SQL.

Busca el ejemplo PreparedSelectParametro y estudialo/ejecutalo.

3. 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: qué índices usar, en qué orden acceder a las tablas, qué filtros aplicar, etc.

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

SELECT * FROM persona WHERE nombre = 'pepe';
SELECT * FROM persona WHERE nombre = 'ana';

Aunque las consultas tienen la misma estructura, el texto final no es idéntico. Con PreparedStatement, la estructura se mantiene estable:

SELECT * FROM persona WHERE nombre = ?;

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

statement.setString(1, "pepe")
statement.setString(1, "ana")

Esto facilita que el SGBDR pueda reutilizar mejor el análisis de la consulta. 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.

4. Inserciones con PreparedStatement

PreparedStatement no solo sirve para SELECT. También se usa en INSERT, UPDATE y DELETE.

data class PersonaNueva(val nombre: String, val email: String)

fun insertarPersona(connection: Connection, persona: PersonaNueva): Int {
    val sql = "INSERT INTO persona (nombre, email) VALUES (?, ?)"

    connection.prepareStatement(sql).use { statement ->
        statement.setString(1, persona.nombre)
        statement.setString(2, persona.email)
        return statement.executeUpdate()
    }
}

Lectura del ejemplo:

  • PersonaNueva representa los datos que se quieren guardar.
  • La consulta tiene dos parámetros: nombre y email.
  • setString(1, persona.nombre) rellena el primer ?.
  • setString(2, persona.email) rellena el segundo ?.
  • executeUpdate() ejecuta el INSERT.
  • La función devuelve el número de filas insertadas.

Un uso correcto comprobaría el resultado:

val filas = insertarPersona(connection, PersonaNueva("Ana", "ana@example.com"))

if (filas == 1) {
    println("Persona insertada correctamente.")
} else {
    println("No se ha insertado ningún registro.")
}

Busca el ejemplo InsertBasico y estudialo/ejecutalo.

5. Actualizaciones y borrados parametrizados

En operaciones que modifican o eliminan datos, PreparedStatement ayuda a evitar errores graves al combinar parámetros con WHERE.

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

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

Lectura del ejemplo:

  • El primer parámetro es el nuevo correo.
  • El segundo parámetro es el identificador de la fila que se modifica.
  • WHERE id = ? evita actualizar toda la tabla.
  • El valor devuelto indica cuántas filas se han actualizado.

Para borrar, el patrón es parecido:

fun eliminarPersona(connection: Connection, id: Int): Int {
    val sql = "DELETE FROM persona WHERE id = ?"

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

Comprobar filas afectadas

En UPDATE y DELETE, que executeUpdate() devuelva 0 puede significar que no existe ningún registro con ese identificador. No siempre es una excepción técnica, pero sí es una situación que la aplicación debe gestionar.

Busca los ejemplos UpdateBasico y DeleteBasico y estudialos/ejecutalos.

6. 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 persona WHERE nombre = ?"
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 persona WHERE nombre = ?"

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

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

SELECT * FROM persona WHERE nombre = 'juan'

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 estudialo/ejecutalo.

7. Buenas prácticas

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

Busca el ejemplo CierreRecursosUse y estudialo/ejecutalo.

8. Conclusión

PreparedStatement es una herramienta básica para escribir acceso a datos seguro y mantenible. Permite separar SQL y datos, reduce errores al construir consultas, ayuda a prevenir inyección SQL y facilita que las operaciones parametrizadas se expresen de forma clara.

La idea clave es que una consulta con parámetros no debe construirse pegando texto. Debe prepararse una vez como estructura SQL y recibir los valores de forma controlada.

Fuentes y referencias

Presentación