sqlx4k

repository·main·Indexed 43 days ago

https://github.com/smyrgeorge/sqlx4k

A coroutine-first SQL toolkit for Kotlin Multiplatform providing compile-time query validation for PostgreSQL, MySQL, MariaDB, and SQLite. It focuses on direct database communication rather than being a full ORM, offering features like KSP-based CRUD generation, transaction propagation via TransactionContext, and support for SQLCipher.

Tokens
20.7K
Snippets
60
Records
71
Agent score
56%

What's inside sqlx4k

  1. Overview of sqlx4k

    main

    sqlx4k is a coroutine-first SQL toolkit designed for Kotlin Multiplatform. It is not an ORM; instead, it provides primitives and utilities for direct database communication. Its primary value proposition is providing control while catching errors early through compile-time SQL syntax and schema validation.

    Key features include:

    • Compile-time validation: Catches SQL syntax and schema errors before runtime.
    • Coroutine-first: Built specifically for Kotlin's asynchronous model.
    • Multiplatform support: Works across JVM, Linux, macOS, Windows, iOS, and Android.
    • Extensibility: Supports integrations like PGMQ (PostgreSQL Message Queue) and SQLDelight.
  2. Implement topic-like routing (Pub/Sub)

    main

    You can implement pub/sub style routing using PGMQ helper functions. This requires running sql/topics.sql in your database first to create the necessary bindings table and functions.

    Workflow:

    1. Create a queue.
    2. Bind a topic pattern (e.g., logs.#) to that queue using pgmq.bind_topic.
    3. Use pgmq.send_topic to publish messages with a routing key. The # character acts as a wildcard matching zero or more segments.
    -- 1) Create a queue and bind a topic pattern
    SELECT pgmq.create('logs_all');
    SELECT pgmq.bind_topic('logs.#', 'logs_all');
    -- '#' matches zero or more segments
    
    -- 2) Dry-run to see where a key would route (no send)
    SELECT *
    FROM pgmq.test_routing('logs.api.error');
    
    -- 3) Publish to all matching queues
    SELECT pgmq.send_topic('logs.api.error', '{"message":"boom"}'::jsonb);
  3. Use SQL savepoints within transactions

    main

    Savepoints allow you to mark a point within an open transaction to which you can later return. This is useful for undoing specific steps (like an optional insert) without rolling back the entire transaction.

    • savepoint(name): Creates a savepoint.
    • rollbackToSavepoint(name): Undoes work performed after the savepoint while keeping the transaction open.
    • releaseSavepoint(name): Discards the savepoint and keeps the work.
    • savepoint(name) { ... }: A block-based version that releases the savepoint on success and rolls back to it on failure/error.

    Note: The Android platform SQLite driver does not support savepoints; the SQLCipher driver does.

    // Block-based savepoint
    savepoint("my_savepoint") {
        // operations that can be undone
    }
  4. Propagate transactions using TransactionContext (coroutines)

    main

    When using coroutines, TransactionContext allows you to propagate a transaction through the coroutine context. This enables composable suspend functions to participate in an existing transaction without explicitly passing Transaction or Driver objects.

    • TransactionContext.new(db) { ... }: Starts a new transaction scope.
    • TransactionContext.current(): Retrieves the active transaction from the current coroutine context.
    • TransactionContext.withCurrent { ... }: Executes a block within the ongoing transaction.
    • TransactionContext.withCurrent(db) { ... }: Runs the block in an ongoing transaction if one exists; otherwise, starts a new one with the provided db.
    fun main() = runBlocking {
        TransactionContext.new(db) {
            execute("insert into sqlx4k ...").getOrThrow()
            doBusinessLogic()
        }
    }
    
    suspend fun doBusinessLogic() {
        val tx = TransactionContext.current()
        tx.execute("update sqlx4k ...").getOrThrow()
    }
  5. Use Context-Parameters for repositories

    main

    Instead of passing a QueryExecutor (like db or a transaction) as an explicit parameter to every method, you can use Kotlin context-parameters. This makes the QueryExecutor an ambient requirement.

    To enable this mode:

    1. Extend ContextCrudRepository<T> instead of CrudRepository<T>.
    2. Declare @Query methods with a context(context: QueryExecutor) receiver.
    3. Ensure the enable-context-parameters setting is enabled (default is off).

    When using this mode, you call methods within a with(db) { ... } block.

    @Repository
    interface Sqlx4kRepository : ContextCrudRepository<Sqlx4k> {
        @Query("SELECT * FROM sqlx4k WHERE id = :id")
        context(context: QueryExecutor)
        suspend fun findOneById(id: Int): Result<Sqlx4k?>
    }
    
    // Usage
    with(db) {
        val one = Sqlx4kRepositoryImpl.findOneById(1).getOrThrow()
    }
  6. Install sqlx4k for different databases

    main

    Depending on your target database, include the corresponding dependency in your project. Replace x.y.z with the current version.

    // PostgreSQL
    implementation("io.github.smyrgeorge:sqlx4k-postgres:x.y.z")
    
    // MySQL
    implementation("io.github.smyrgeorge:sqlx4k-mysql:x.y.z")
    
    // SQLite
    implementation("io.github.smyrgeorge:sqlx4k-sqlite:x.y.z")
  7. Implement CRUD and @Repository using KSP

    main

    sqlx4k uses KSP (Kotlin Symbol Processing) to generate CRUD implementations and RowMappers based on your data classes and interfaces.

    1. Setup KSP

    Add the KSP plugin and the sqlx4k-codegen dependency to your project. You must configure the output-package argument.

    2. Define Entities and Repositories

    • Annotate data classes with @Table("table_name").
    • Annotate interfaces with @Repository and extend CrudRepository<T>.
    • Use @Query("SQL") to define custom queries. The method name prefix determines the return type (e.g., findAll returns Result<List<T>>, findOneBy... returns Result<T?>).

    3. Usage

    Access generated methods via the implementation class (e.g., Sqlx4kRepositoryImpl).

    // KSP Configuration
    ksp {
        arg("output-package", "io.github.smyrgeorge.sqlx4k.examples.postgres")
    }
    
    // Entity
    @Table("sqlx4k")
    data class Sqlx4k(
        @Id(insert = true) val id: Int,
        val test: String
    )
    
    // Repository
    @Repository
    interface Sqlx4kRepository : CrudRepository<Sqlx4k> {
        @Query("SELECT * FROM sqlx4k WHERE id = :id")
        suspend fun findOneById(context: QueryExecutor, id: Int): Result<Sqlx4k?>
    }
    
    // Usage
    val res: Sqlx4k = Sqlx4kRepositoryImpl.insert(db, record).getOrThrow()
  8. Configure native SQLite libraries for Android

    main

    Android targets require manual setup of native SQLite libraries because the transitive sqlite-jdbc dependency does not include them for Android.

    1. Download the required .so files from the xerial/sqlite-jdbc repository for your target architectures (aarch64, arm, x86, x86_64).
    2. Place them in the following jniLibs directory structure within your Android module:
    src/
    └── main/
        └── jniLibs/
            ├── arm64-v8a/
            │   └── libsqlitejdbc.so
            ├── armeabi-v7a/
            │   └── libsqlitejdbc.so
            ├── x86/
            │   └── libsqlitejdbc.so
            └── x86_64/
                └── libsqlitejdbc.so

    Mapping Table:

    • Linux-Android/aarch64/libsqlitejdbc.so $\rightarrow$ jniLibs/arm64-v8a/libsqlitejdbc.so
    • Linux-Android/arm/libsqlitejdbc.so $\rightarrow$ jniLibs/armeabi-v7a/libsqlitejdbc.so
    • Linux-Android/x86/libsqlitejdbc.so $\rightarrow$ jniLibs/x86/libsqlitejdbc.so
    • Linux-Android/x86_64/libsqlitejdbc.so $\rightarrow$ jniLibs/x86_64/libsqlitejdbc.so
    # Example: Setup for ARM64
    mkdir -p src/main/jniLibs/arm64-v8a
    
    curl -L https://github.com/xerial/sqlite-jdbc/raw/master/src/main/resources/org/sqlite/native/Linux-Android/aarch64/libsqlitejdbc.so \
      -o src/main/jniLibs/arm64-v8a/libsqlitejdbc.so
  9. Quick start with PgmqClient

    main

    To use PGMQ, set up a PostgreSQL connection, initialize a PgmqClient with a PgmqDbAdapterImpl, and manage queues. You can enable enableNotifyInsert on a queue to use PostgreSQL LISTEN/NOTIFY for near real-time processing.

    Key operations include:

    • create(): Initializes the client and validates/installs PGMQ.
    • send(queueName, payload): Sends a message to a specific queue.
    • read(queueName, quantity, vt): Reads messages with a specific visibility timeout (vt).
    • ack(queueName, msgId): Acknowledges a message so it is removed from the queue.
    // Set up a PostgreSQL connection
    val db = PostgreSQL(
        url = "postgresql://localhost:5432/test",
        username = "postgres",
        password = "postgres"
    )
    
    // Create client (auto-installs/validates PGMQ)
    val pgmq = PgmqClient(
        pg = PgmqDbAdapterImpl(db),
        options = PgmqClient.Options(autoInstall = true, verifyInstallation = true)
    )
    
    // Create a queue (enable NOTIFY for near real-time consumers)
    val queue = PgmqClient.Queue(name = "my_queue", enableNotifyInsert = true)
    pgmq.create().getOrThrow()
    
    // Send
    pgmq.send("my_queue", "{\"orderId\": 1}").getOrThrow()
    
    // Read + ack
    pgmq.read("my_queue", quantity = 1, vt = 30.seconds).getOrThrow().forEach { msg ->
        // process(msg)
        pgmq.ack("my_queue", msg.msgId).getOrThrow()
    }