Skip to content
caffeine-mgnPublic

About

SQLite Implementation for KMP

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

ksqlite

Maven Central License: Apache-2.0 Kotlin SQLite sqlite-vec

A pure SQLite library for Kotlin Multiplatform. The C amalgamation (sqlite3.c, plus extensions) is linked straight into every native klib; on the desktop JVM a JNI .so is built and loaded at runtime, and on Android the same bionic .so is shipped inside an AAR (jniLibs) and loaded through System.loadLibrary — there is no third-party JDBC driver, no sqlite-jdbc on the classpath, no Java-side shadow of the engine.

The API is identical across JVM, Linux/macOS/Windows native, all of Apple's platforms (iOS / macOS / tvOS / watchOS, devices and simulators), and Android (the AAR for JVM/ART apps or the androidNative* Kotlin/Native targets — both are built from the same C amalgamation). You open a connection, prepare statements, and read typed columns.

Current version: 0.1.6.

Supported targets

Target Backend
jvm (desktop: linux/macOS/windows) dynamic .so / .dylib / .dll loaded via JNI
android (Dalvik/ART, all 4 ABIs) AAR: bionic .so under jniLibs, loaded via System.loadLibrary
linuxX64, linuxArm64 static C amalgamation linked into the klib
macosX64, macosArm64 static C amalgamation linked into the klib
iosX64, iosArm64, iosSimulatorArm64 static C amalgamation linked into the klib
tvosX64, tvosArm64, tvosSimulatorArm64 static C amalgamation linked into the klib
watchosX64, watchosArm32, watchosArm64, watchosSimulatorArm64 static C amalgamation linked into the klib
mingwX64 static C amalgamation linked into the klib
androidNativeArm32/Arm64/X86/X64 static C amalgamation linked into the klib

The native targets are configured with kn-clang-compiler-plugin; no cinterop toolchain install is required from the consumer.

Apple targets need a Mac host. The Kotlin Multiplatform plugin auto-disables macos*, ios*, tvos* and watchos* on Linux and Windows, so the CI release (which runs on ubuntu-latest like the rest of the caffeine-mgn libraries) ships the JVM + Linux + Android + Mingw slice. To consume an Apple klib, build the project locally on macOS (./gradlew publishToMavenLocal) or wait for an Apple-target CI to be added.

Built-in extensions

Extension Version Auto-loaded
sqlite-vec 0.1.9 yes — registered via sqlite3_auto_extension on JVM startup, statically linked into every native klib
JSON1 bundled compiled into the amalgamation with -DSQLITE_ENABLE_JSON1
FTS3 / FTS4 / FTS5 bundled full-text search compiled into the amalgamation
RTREE bundled R-tree spatial index compiled into the amalgamation

sqlite-vec gives you virtual vec0 tables for vector search (k-nearest neighbours, cosine / L2 / Hamming distance, etc.). The engine is configured with SQLITE_THREADSAFE=1, FTS3/4/5, RTREE, JSON1, COLUMN_METADATA, DBSTAT_VTAB, EXPLAIN_COMMENTS, UNLOCK_NOTIFY, UPDATE_DELETE_LIMIT through the sqlite3.c amalgamation in src/native/. See the comments in build.gradle.kts (SQLITE_COMPILE_FLAGS) for the exact list.

Add your own out-of-tree extension by dropping the C file into src/native/ and appending it to the compileFile(...) calls in build.gradle.kts.

Versioning

The published version is taken from the GITHUB_REF_NAME environment variable in CI (the tag itself, e.g. 0.1.6) and falls back to 0.1.6 locally.

Installation

Add the dependency to your KMP module (Gradle Kotlin DSL, 0.1.6):

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("pw.binom.db:ksqlite:0.1.6")
        }
    }
}

The native targets pull the right static klib automatically. On the desktop JVM nothing extra is required — the native library is built by Gradle at compile time and extracted on first use into the user's cache directory (see NativeLoader). Android resolves the same coordinate to an AAR that carries the bionic .so per ABI under jni/<abi>/libksqlite.so (AGP installs them into lib/<abi>/ of the APK); the androidMain source set loads them with System.loadLibrary("ksqlite") — no extraction and no writable-path requirement. If the desktop host platform has no bundled .so the loader throws an explicit IllegalStateException listing the supported ones.

Examples

Opening a connection

import pw.binom.db.ksqlite.SQLiteConnection

SQLiteConnection.open("app.db").use { conn ->
    conn.exec("""
        CREATE TABLE IF NOT EXISTS users (
            id    INTEGER PRIMARY KEY,
            name  TEXT NOT NULL,
            score REAL
        )
    """)
}

There are also SQLiteConnection.memory(name = "cache") for an in-memory database and SQLiteConnection.temporary() for a temp file that is deleted on close.

Reading typed columns

conn.prepare("SELECT id, name, score FROM users WHERE id = ?").use { stmt ->
    stmt.bind(1, 42L)
    stmt.executeQuery().use { rs ->
        if (rs.next()) {
            val id    = rs.getLong(0)        // nullable boxed types for SQL NULL
            val name  = rs.getString(1)
            val score = rs.getDouble(2)
        }
    }
}

Supported column accessors: getLong, getInt, getDouble, getFloat, getBoolean, getString, getBlob, getBytes, getNull, plus the same set keyed by column name. getBytes / getBlob returns a ByteArray, getString is UTF-8. JSON columns are read with getJson (see below).

Parameter binding

conn.prepare("INSERT INTO users(name, score) VALUES (?, ?)").use { stmt ->
    stmt.bind(1, "alice")
    stmt.bind(2, 0.95)
    stmt.executeUpdate()   // returns the number of affected rows
}

Supported bind types: Long, Int, Double, Float, Boolean, String, ByteArray, FloatArray (vector). null is bound as SQL NULL.

Transactions

conn.beginTransaction()
try {
    conn.exec("INSERT INTO users(name, score) VALUES ('bob', 1.0)")
    conn.exec("UPDATE users SET score = score + 1 WHERE name = 'bob'")
    conn.commit()
} catch (e: Throwable) {
    conn.rollback()
    throw e
}

There is no try-with-resources for transactions on purpose — that is what the try { ... } catch { rollback() } finally { /* connection close */ } block above models.

Vector search with sqlite-vec

sqlite-vec is enabled out of the box — no separate registration step.

conn.exec("""
    CREATE VIRTUAL TABLE IF NOT EXISTS items USING vec0(
        embedding float[4] distance_metric=cosine
    )
""")

val query = floatArrayOf(0.1f, 0.2f, 0.3f, 0.4f)

conn.prepare("INSERT INTO items(rowid, embedding) VALUES (?, ?)").use { stmt ->
    // rowid is the row identifier, embedding is the vector
    listOf(
        1L to floatArrayOf(0.10f, 0.20f, 0.30f, 0.40f),
        2L to floatArrayOf(0.11f, 0.21f, 0.31f, 0.41f),
        3L to floatArrayOf(0.00f, 0.00f, 1.00f, 0.00f),
    ).forEach { (id, vec) ->
        stmt.bind(1, id)
        stmt.bind(2, vec)
        stmt.executeUpdate()
    }
}

conn.prepare(
    """
    SELECT rowid, distance
      FROM items
     WHERE embedding MATCH ?
     ORDER BY distance
     LIMIT 5
    """.trimIndent(),
).use { stmt ->
    stmt.bind(1, query)
    stmt.executeQuery().use { rs ->
        while (rs.next()) {
            val rowId    = rs.getLong(0)
            val distance = rs.getDouble(1)   // cosine distance
            println("$rowId  $distance")
        }
    }
}

Use Vector(...).fill(floats) to build a typed value for binding without constructing a raw FloatArray:

val v = Vector(FloatDimension(4)).fill(0.1f, 0.2f, 0.3f, 0.4f)
stmt.bind(1, v)

Vector.toFloatArray() and Vector.asBytes() (for BLOB columns) cover the other directions.

JSON via the JSON1 extension

JSON1 is compiled into the amalgamation with -DSQLITE_ENABLE_JSON1, so every TEXT column can act as a JSON document and the SQL functions (json_extract, json_array, json_object, json_each, …) work straight away:

conn.exec("""
    CREATE TABLE docs (id INTEGER PRIMARY KEY, payload TEXT);
    INSERT INTO docs (payload) VALUES
      ('{"name":"alice","tags":["a","b"]}'),
      ('{"name":"bob","age":30}');
""")

conn.prepare("""
    SELECT id,
           json_extract(payload, '$.name') AS name,
           payload
    FROM docs
    ORDER BY id
""").use { stmt ->
    stmt.executeQuery().use { rs ->
        while (rs.next()) {
            val name = rs.getText(1)         // "alice", "bob"
            val raw  = rs.getJson(2)        // Json(text = "{...}")
            // parse raw.text with kotlinx.serialization / your favourite lib
        }
    }
}

Json is just a typed wrapper around the raw JSON text — getJson returns null on SQL NULL and the underlying text is preserved verbatim. There is no Kotlin-side parsing because most projects will reach for kotlinx.serialization or their own JSON layer; the API just gives you a self-describing accessor that won't silently coerce to a String.

Convenience queries

val rowCount = conn.queryForInt("SELECT COUNT(*) FROM users", 0)
val name     = conn.queryForString("SELECT name FROM users WHERE id = ?", 0, 42L)

queryForXxx(...) runs a statement, advances to the first row, reads column 0 and frees the statement. There are variants for Long, Int, Double, Boolean, String, ByteArray.

Build

./gradlew build           # compile every configured target
./gradlew jvmTest         # run the JVM tests (builds + loads the .so)
./gradlew linuxX64Test    # run the linuxX64 tests
./gradlew assembleDebug    # build the Android AAR (needs ANDROID_HOME or local.properties sdk.dir)

Other natives (macosArm64, iosArm64, mingwX64, …) are skipped on Linux hosts because the corresponding toolchain is unavailable; the JVM and Linux tests run everywhere.

Loading the native library on the JVM

The JVM target ships only the bytecode and a single platform-specific .so that is compiled at build time by the same Gradle module (see clangBuildDynamic in build.gradle.kts). On first use, NativeLoader copies that artifact into the user's cache directory (~/.cache/ksqlite on Linux, the equivalent of XDG_CACHE_HOME elsewhere) and verifies it with the SHA-256 hash baked into the JAR. No /tmp paths, no mutable global state.

Android does not use NativeLoader. The .so files are built by the same clangBuildDynamic tasks (targeting the NDK/bionic toolchain), copied into build/androidJniLibs/<abi>/libksqlite.so and packaged into the AAR by AGP so they end up in lib/<abi>/ of the consuming APK. The androidMain actual of loadNativeLibrary simply calls System.loadLibrary("ksqlite") — the only loading mechanism the Android linker permits for an app's own native libraries (unpacking a .so from the JAR into app storage and calling System.load is blocked by the linker namespace and SELinux on API 24+).

Both Android build types (release and debug) are published. An Android application's runtime classpath carries the debug build-type attribute, so without a matching debug variant Gradle would find no exact Android candidate and silently fall back to the plain ksqlite-jvm JAR (which has no jniLibs and cannot load on Android). Publishing both variants — publishLibraryVariants on androidTarget — guarantees the AAR is selected.

About

SQLite Implementation for KMP

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages