Base de datos SQLite en Java con JDBC

SQLite es un archivo, no un servidor: no hay nada que instalar ni que arrancar. Con un solo jar y java.sql, que ya viene en el JDK, tienes una base de datos de verdad dentro del programa.

El controlador

curl -LO https://repo1.maven.org/maven2/org/xerial/sqlite-jdbc/3.53.4.0/sqlite-jdbc-3.53.4.0.jar
salidacontrolador: SQLite JDBC 3.53.4.0

Es el único añadido: la API —Connection, PreparedStatement, ResultSet— está en el JDK desde siempre. Desde Java 6 ni siquiera hace falta Class.forName: el controlador se registra solo si está en el classpath.

Un aviso nuevo en Java 25. Al arrancar sale esto, porque el controlador carga una biblioteca nativa:

sin la banderaWARNING: A restricted method in java.lang.System has been called
WARNING: java.lang.System::load has been called by org.sqlite.SQLiteJDBCLoader in an unnamed module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

Se quita añadiendo --enable-native-access=ALL-UNNAMED al lanzar. Y conviene hacerlo: el último mensaje avisa de que en una versión futura esto no será un aviso, será un error.

Crear la tabla

st.execute("""
    CREATE TABLE libros (
        id     INTEGER PRIMARY KEY AUTOINCREMENT,
        titulo TEXT    NOT NULL,
        autor  TEXT    NOT NULL,
        anio   INTEGER NOT NULL,
        notas  TEXT,
        UNIQUE(titulo, autor))""");

Insertar y recuperar la clave generada

try (var ps = cn.prepareStatement(
        "INSERT INTO libros (titulo, autor, anio) VALUES (?, ?, ?)",
        Statement.RETURN_GENERATED_KEYS)) {
    ps.setString(1, "Rayuela"); ps.setString(2, "Julio Cortázar"); ps.setInt(3, 1963);
    ps.executeUpdate();
    try (var k = ps.getGeneratedKeys()) { if (k.next()) IO.println("id generado: " + k.getLong(1)); }
}
salidaid generado: 1

Los parámetros se numeran desde 1, no desde 0. Es un despiste clásico y da un error poco claro.

Muchos de golpe: lote y transacción

cn.setAutoCommit(false);
try (var ps = cn.prepareStatement("INSERT INTO libros (titulo, autor, anio) VALUES (?, ?, ?)")) {
    for (Object[] l : mas) {
        ps.setString(1, (String) l[0]); ps.setString(2, (String) l[1]); ps.setInt(3, (Integer) l[2]);
        ps.addBatch();
    }
    int[] n = ps.executeBatch();
    cn.commit();
} catch (SQLException e) { cn.rollback(); throw e; }
finally { cn.setAutoCommit(true); }
salidainsertados 3 en lote

En SQLite esto no es una cuestión de estilo. Con autoCommit activado, cada INSERT es una transacción con su sincronización a disco: meter mil filas una a una frente a hacerlo en una transacción son dos órdenes de magnitud de diferencia. El finally que restaura autoCommit importa si la conexión se reutiliza.

Consultar

try (var ps = cn.prepareStatement(
        "SELECT id, titulo, autor, anio, notas FROM libros WHERE anio < ? ORDER BY anio")) {
    ps.setInt(1, 1990);
    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            String notas = rs.getString("notas");
            if (rs.wasNull()) notas = "(sin notas)";
            ...
        }
    }
}
salida3  Ficciones                  Jorge Luis Borges  1944  (sin notas)
  2  Pedro Páramo               Juan Rulfo         1955  (sin notas)
  1  Rayuela                    Julio Cortázar     1963  (sin notas)
  4  La casa de los espíritus   Isabel Allende     1982  (sin notas)
wasNull() es la rareza de JDBC que hay que conocer. getInt de una columna nula devuelve 0 y getString devuelve null, así que con los números no puedes distinguir un cero de verdad de un nulo. La única forma es llamar a wasNull() justo después del get: se refiere a la última columna leída.

El error de la restricción

insertando el mismo libro otra vez[SQLITE_CONSTRAINT_UNIQUE] A UNIQUE constraint failed (UNIQUE constraint failed: libros.titulo, libros.autor)   código 19

El 19 es el código de SQLite para «violación de restricción». Para distinguir «ya existe» de «error interno» sin buscar texto dentro del mensaje, se compara e.getErrorCode(), o mejor e.getSQLState(), que es el código estándar y no depende del motor.

Los interrogantes no son concatenación

ps.setString(1, "x'; DROP TABLE libros; --");
salidafilas: 0
  y la tabla sigue con 4 libros

El valor viaja aparte de la consulta y la base de datos nunca lo interpreta como SQL: para ella es un nombre de autor larguísimo y raro que no existe. Esto deja de ser cierto en cuanto construyes la consulta con + o con String.format, y ahí es donde aparecen las inyecciones.

Lo que no se puede parametrizar son los nombres de tabla y de columna. Si tienen que ser variables, valídalos contra una lista blanca.

Para… Usa Y no olvides
Conectar DriverManager.getConnection("jdbc:sqlite:ruta") try con recursos
Insertar o actualizar ps.executeUpdate() los parámetros empiezan en 1
Muchas filas addBatch + executeBatch dentro de una transacción
Consultar ps.executeQuery() cerrar el ResultSet
Columnas nulas rs.wasNull() justo después del get
Valores del usuario siempre ? nunca concatenar
Varios escritores ?journal_mode=WAL en la URL

Todo el código se compiló y se ejecutó con Java 25 LTS en un contenedor limpio antes de publicar esta página; las salidas están copiadas de esa ejecución.