SQLite en Kotlin con JDBC: crear tablas, consultas con parámetros y transacciones

SQLite es una base de datos completa en un solo archivo, sin servidor que instalar. Desde Kotlin se usa con JDBC, la misma interfaz que en Java, pero con menos ceremonia: use cierra los recursos, las filas se convierten en data classes y los parámetros mantienen tus consultas a salvo.

Lo que necesitas

SQLite guarda toda la base de datos en un solo archivo y no necesita un servidor: es ideal para herramientas de línea de órdenes, aplicaciones de escritorio y pruebas. Desde Kotlin, en la JVM, se usa con JDBC, la interfaz estándar de Java para bases de datos, y el driver sqlite-jdbc, que ya incluye la biblioteca nativa de SQLite para Linux, macOS y Windows. En Gradle:

dependencies {
    implementation("org.xerial:sqlite-jdbc:3.54.1.0")
}

O a mano, con el jar en el classpath: kotlinc crear.kt -d salida y java -cp salida:sqlite-jdbc-3.54.1.0.jar:$KOTLIN_HOME/lib/kotlin-stdlib.jar CrearKt.

Crear la tabla, insertar y consultar

import java.sql.DriverManager

data class Producto(val id: Int, val nombre: String, val precio: Double, val existencias: Int)

fun main() {
    DriverManager.getConnection("jdbc:sqlite:tienda.db").use { conexion ->
        conexion.createStatement().use { st ->
            st.executeUpdate("""
                CREATE TABLE IF NOT EXISTS productos (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    nombre TEXT NOT NULL UNIQUE,
                    precio REAL NOT NULL,
                    existencias INTEGER NOT NULL DEFAULT 0
                )
            """.trimIndent())
        }

        val nuevos = listOf("Café molido" to 89.50, "Té verde" to 54.90, "Cacao puro" to 120.00)
        conexion.prepareStatement("INSERT INTO productos (nombre, precio, existencias) VALUES (?, ?, ?)").use { ps ->
            for ((nombre, precio) in nuevos) {
                ps.setString(1, nombre)
                ps.setDouble(2, precio)
                ps.setInt(3, 10)
                ps.executeUpdate()
            }
        }

        val productos = mutableListOf<Producto>()
        conexion.prepareStatement("SELECT id, nombre, precio, existencias FROM productos ORDER BY precio").use { ps ->
            ps.executeQuery().use { rs ->
                while (rs.next()) {
                    productos += Producto(rs.getInt("id"), rs.getString("nombre"), rs.getDouble("precio"), rs.getInt("existencias"))
                }
            }
        }
        productos.forEach(::println)
    }
}
salidaWARNING: 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 (file:/lib-kt/sqlite/sqlite-jdbc-3.54.1.0.jar)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

Producto(id=2, nombre=Té verde, precio=54.9, existencias=10)
Producto(id=1, nombre=Café molido, precio=89.5, existencias=10)
Producto(id=3, nombre=Cacao puro, precio=120.0, existencias=10)

Las cuatro primeras líneas no son un error: desde Java 24, la JVM avisa cuando una biblioteca carga código nativo, y sqlite-jdbc carga el motor de SQLite, escrito en C. El programa funciona igual. Para quitar el aviso, arranca con java --enable-native-access=ALL-UNNAMED ... (en Gradle, applicationDefaultJvmArgs). En las salidas siguientes lo omitimos.

La URL jdbc:sqlite:tienda.db abre el archivo tienda.db de la carpeta actual y lo crea si no existe. Tres detalles importantes:

  • use { } cierra solo la conexión, la sentencia y el ResultSet, aunque haya una excepción a la mitad. Es el equivalente de Kotlin al try-with-resources de Java. Sin cerrar, la base puede quedarse bloqueada.
  • Los valores van con ? y setString, setDouble, setInt, nunca pegados al texto del SQL. Lo explicamos abajo.
  • Cada fila se convierte en una data class: a partir de ahí trabajas con objetos de Kotlin, no con columnas.

Si cambias algo y lo ejecutas otra vez, verás que el INSERT falla: nombre es UNIQUE y los productos ya existen en el archivo. Bórralo para empezar de cero.

Buscar con un texto del usuario: siempre con parámetros

import java.sql.Connection
import java.sql.DriverManager

fun preparar(c: Connection) {
    c.createStatement().use { st ->
        st.executeUpdate("CREATE TABLE productos (nombre TEXT NOT NULL UNIQUE, precio REAL NOT NULL)")
        st.executeUpdate("INSERT INTO productos VALUES ('Café molido', 89.5), ('Café de olla', 75.0), ('Té verde', 54.9)")
    }
}

fun buscarPorNombre(c: Connection, texto: String): List<String> =
    c.prepareStatement("SELECT nombre, precio FROM productos WHERE nombre LIKE ? ORDER BY nombre").use { ps ->
        ps.setString(1, "%$texto%")
        ps.executeQuery().use { rs ->
            buildList { while (rs.next()) add("${rs.getString(1)} (${rs.getDouble(2)})") }
        }
    }

fun main(args: Array<String>) {
    DriverManager.getConnection("jdbc:sqlite:tienda.db").use { c ->
        preparar(c)
        for (texto in args) {
            println("«$texto»: ${buscarPorNombre(c, texto)}")
        }
    }
}
java -cp salida:sqlite-jdbc-3.54.1.0.jar:$KOTLIN_HOME/lib/kotlin-stdlib.jar BuscarKt Café verde "O'Higgins"
salida«Café»: [Café de olla (75.0), Café molido (89.5)]
«verde»: [Té verde (54.9)]
«O'Higgins»: []

El texto que escribe el usuario viaja separado del SQL: el driver lo trata siempre como un dato, nunca como parte de la consulta. Por eso «O’Higgins», con su apóstrofo, funciona sin más (simplemente no encuentra nada), cuando pegado con "... LIKE '%" + texto + "%'" rompería la consulta. Esa es también la defensa contra la inyección SQL, uno de los fallos de seguridad más comunes: si construyes SQL concatenando lo que llega de fuera, alguien puede cambiar lo que hace tu consulta. Con parámetros ? no puede. Úsalos siempre, aunque el dato te parezca inofensivo.

Transacciones: todo o nada

import java.sql.Connection
import java.sql.DriverManager
import java.sql.SQLException

fun saldos(c: Connection): String =
    c.createStatement().use { st ->
        st.executeQuery("SELECT titular, saldo FROM cuentas ORDER BY titular").use { rs ->
            buildList { while (rs.next()) add("${rs.getString(1)}=${rs.getInt(2)}") }.joinToString(", ")
        }
    }

fun transferir(c: Connection, de: String, a: String, monto: Int) {
    c.autoCommit = false
    try {
        c.prepareStatement("UPDATE cuentas SET saldo = saldo - ? WHERE titular = ?").use { ps ->
            ps.setInt(1, monto); ps.setString(2, de); ps.executeUpdate()
        }
        c.prepareStatement("UPDATE cuentas SET saldo = saldo + ? WHERE titular = ?").use { ps ->
            ps.setInt(1, monto); ps.setString(2, a); ps.executeUpdate()
        }
        c.commit()
        println("Transferencia de $monto hecha")
    } catch (e: SQLException) {
        c.rollback()
        println("Deshecha: ${e.message}")
    } finally {
        c.autoCommit = true
    }
}

fun main() {
    DriverManager.getConnection("jdbc:sqlite:banco.db").use { c ->
        c.createStatement().use {
            it.executeUpdate("CREATE TABLE cuentas (titular TEXT PRIMARY KEY, saldo INTEGER NOT NULL CHECK (saldo >= 0))")
            it.executeUpdate("INSERT INTO cuentas VALUES ('Ana', 500), ('Beto', 100)")
        }
        println(saldos(c))
        transferir(c, "Ana", "Beto", 200)
        println(saldos(c))
        transferir(c, "Beto", "Ana", 1000)
        println(saldos(c))
    }
}
salidaAna=500, Beto=100
Transferencia de 200 hecha
Ana=300, Beto=300
Deshecha: [SQLITE_CONSTRAINT_CHECK] A CHECK constraint failed (CHECK constraint failed: saldo >= 0)
Ana=300, Beto=300

Una transferencia son dos UPDATE, y nunca debe quedar hecho solo uno. Con autoCommit = false los cambios se acumulan hasta commit(); si algo falla, rollback() los deshace todos. En la segunda transferencia, a Beto no le alcanza: la restricción CHECK (saldo >= 0) rechaza el primer UPDATE y la base queda exactamente como estaba. Fíjate en que JDBC llama a esto setAutoCommit, y Kotlin te deja escribirlo como una propiedad: c.autoCommit = false.

El error típico: el driver no está en el classpath

Este programa compila sin la biblioteca, porque java.sql viene con el JDK. Pero al ejecutarlo sin el jar de sqlite-jdbc:

import java.sql.DriverManager

fun main() {
    DriverManager.getConnection("jdbc:sqlite:tienda.db").use { println("conectado") }
}
salida con errorException in thread "main" java.sql.SQLException: No suitable driver found for jdbc:sqlite:tienda.db

«No suitable driver» casi nunca es un problema de la URL: es que falta el jar del driver al ejecutar. En Gradle, revisa que esté en implementation y no solo en compileOnly.

  • ParaUsa
  • Abrir la baseDriverManager.getConnection("jdbc:sqlite:archivo.db")
  • Cerrar siempre.use { } en conexión, sentencia y ResultSet
  • Valores en la consulta? con setString, setInt…
  • Leer filaswhile (rs.next()) y rs.getString("columna")
  • Todo o nadaautoCommit = false, commit(), rollback()
  • Base temporal en memoriajdbc:sqlite::memory:

JDBC es la base de todo lo demás. Para proyectos grandes, bibliotecas como Exposed (de JetBrains) o SQLDelight generan este código por ti con consultas comprobadas al compilar, pero por dentro hacen lo mismo que aquí.

Todo el código de esta página se compiló con Kotlin 2.4.21 y se ejecutó en la JVM de Temurin 25.0.5 en un contenedor limpio y sin red antes de publicarla; las salidas y los mensajes del compilador están copiados de esa ejecución.