Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

- Date format: YYYY-MM-dd

## 2.6.0 / 2026-xx-xx

### sqllin-dsl

* Fix: the default `lookasideSlotSize` and `lookasideSlotCount` of `DSLDBConfiguration` disabled SQLite's lookaside memory allocator on Kotlin/Native and on Android 9 and higher, as those of `DatabaseConfiguration` did; they are now -1, see sqllin-driver
* Fix: on the JVM and Kotlin/Native, a blocking `database { }` scope on another thread could take the TEMP table and triggers that count the changes of observed queries with it, as its transaction can be open while they are created, the connection being locked for each statement rather than for a transaction: its rollback removed them, so the next write failed with "no such table", and once another table was observed, the changes of the table they belonged to were no longer counted. The tracker now creates them again where they are gone, and counts a change of their tables. A failure to tell the observed queries of a change no longer takes the place of the failure of the statement that made it. The guide to observed queries and the documentation of `Database#invoke` and `Database#observe` now say to write through `suspendedScope` while queries are observed, as they run on another thread, and that a blocking scope isn't serialized with them
* Fix documentation: the example of the guide to concurrency safety called `database suspendScope { }`, which doesn't exist, in a function that wasn't a `suspend` one; it is `database suspendedScope { }`, in a `suspend` function

### sqllin-driver

* Fix: the default `lookasideSlotSize` and `lookasideSlotCount` of `DatabaseConfiguration`, 0, which its documentation said left SQLite's default configuration, disabled SQLite's lookaside memory allocator on Kotlin/Native and on Android 9 and higher, as `sqlite3_db_config` and `setLookasideConfig` take 0 to disable it, while the JVM and lower versions of Android kept SQLite's default configuration. Their default is now -1, which leaves SQLite's default configuration on every platform, and 0 still disables the allocator. The guides to getting started now say so, and that sqlite-jdbc doesn't support `sqlite3_db_config()`, rather than `sqlite3_config()`
* Fix: on Android, `busyTimeout` didn't take effect, so SQLite waited 2500 ms, as the framework sets it, for a lock that another connection held. On Android 9 and higher, it was given to `SQLiteDatabase.OpenParams.Builder#setIdleConnectionTimeout`, which is deprecated and means another thing, how long a connection may be idle before the framework closes it, and which `SQLiteOpenHelper` drops anyway. `busyTimeout` is now set with `PRAGMA busy_timeout`: from Android 13 on, on every connection of the database, as one in WAL mode reads through several, and below, on its only connection. The guides to getting started listed `busyTimeout`, and in Chinese `journalMode`, among the parameters that only work on Android 9 and higher; only `inMemory`, `lookasideSlotSize` and `lookasideSlotCount` do
* Fix: transactions began differently on each platform, DEFERRED on the JVM and Kotlin/Native and EXCLUSIVE on Android, so on the JVM and Kotlin/Native, a transaction that read before it wrote failed at once with "database is locked" while another connection wrote, as SQLite doesn't wait for `busyTimeout` there, where waiting could deadlock. Transactions now begin IMMEDIATE on every platform, taking the write lock at once, for which SQLite waits as long as `busyTimeout`. On Android, it is what EXCLUSIVE did in WAL mode, and in DELETE mode, other connections can now read until the transaction commits. On the JVM and Kotlin/Native, a read-only connection begins a deferred transaction, as it can't take the write lock. The JVM driver begins and ends transactions with SQL rather than by leaving and entering auto-commit mode, as sqlite-jdbc then begins another transaction of the configured kind at once, which an IMMEDIATE one couldn't while another connection wrote
* Fix: when SQLite rolled a transaction back itself, as it does for a constraint with `ON CONFLICT ROLLBACK`, which `onConflict = OnConflict.ROLLBACK` declares, for a trigger's `RAISE(ROLLBACK)` and for some I/O errors, ending the transaction failed too, as no transaction was active, and that failure took the place of the one that made SQLite roll it back, in `withTransaction` and so in the DSL's `transaction { }`. The failure that made SQLite roll it back is now thrown, with the failure to end the transaction added to it as a suppressed exception

## 2.5.0 / 2026-10-11

### All
Expand Down
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ done. A new item takes the next number.

* F8: Support WASM platform DSL
* F12: Support type converters: store a property of any type through a serializer that encodes it to a type SQLite supports, with type-safe WHERE and SET on its column, e.g. to store instances of kotlinx.datetime
* F13: Provide a `PagingSource` for androidx.paging built on observable queries, like Room's `LimitOffsetPagingSource`, so that paging a query doesn't need a hand-written bridge
* F66: Provide table version tokens, which tell a reader when the tables it read change, such as a `PagingSource` that has to invalidate itself: `versioned { }` runs a read-only scope and returns its results with an opaque token of the versions of the tables its SELECTs read, captured under the lock that the read runs under, after the tables are tracked; tokens combine with `+`, which takes the earlier version of a table and rejects the tokens of another `Database`; `awaitChange(token)` suspends until one of their tables changes, and `onChange(token) { }` registers a one-shot callback, which runs after the writing scope releases the lock, and returns null instead when the token is already stale, as it registers before it checks, so that no change between the read and the registration is missed
* F13: Provide a `PagingSource` for androidx.paging built on the table version tokens of F66, like Room's `LimitOffsetPagingSource`, so that paging a query doesn't need a hand-written bridge: it registers with `onChange` from its first load, holding its callback strongly while the registry holds it weakly, so that a source that Pager abandons doesn't stay registered, keeps the previous key of an invalidated offset-keyed source, reads `anchorPosition` relative to the presented list without placeholders, and keeps offsets on a grid of the page size
* F14: Support constraints in `ALTER_ADD_COLUMN`, which adds a column with its type only: DEFAULT, NOT NULL with DEFAULT, CHECK, COLLATE NOCASE and REFERENCES
* F15: Support WITHOUT ROWID tables, which save space and a lookup when the primary key isn't an INTEGER
* F16: Support STRICT tables, which reject values of other types than their columns' (SQLite 3.37.0, Android API 34)
Expand All @@ -20,7 +21,7 @@ done. A new item takes the next number.
* F23: Support the other conflict resolutions: INSERT OR ABORT, OR FAIL and OR ROLLBACK, and UPDATE OR IGNORE, OR REPLACE and the others
* F25: Support ORDER BY and LIMIT in UPDATE and DELETE, which need SQLite's `SQLITE_ENABLE_UPDATE_DELETE_LIMIT` option, which Android's lacks
* F26: Support INDEXED BY and NOT INDEXED
* F27: Support choosing how a transaction begins, DEFERRED, IMMEDIATE or EXCLUSIVE, which differs between the platforms now
* F27: Support choosing how a transaction begins, DEFERRED or EXCLUSIVE, besides IMMEDIATE, which every platform uses since 2.6.0
* F29: Support EXPLAIN QUERY PLAN, to see which indexes a query uses
* F30: Support more PRAGMAs, such as integrity_check, quick_check, table_info, index_list, wal_checkpoint, defer_foreign_keys and case_sensitive_like

Expand Down
4 changes: 2 additions & 2 deletions sqllin-driver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ val databaseConnection = openDatabase(
journalMode = JournalMode.WAL,
synchronousMode = SynchronousMode.NORMAL,
busyTimeout = 5000,
lookasideSlotSize = 0,
lookasideSlotCount = 0,
lookasideSlotSize = -1,
lookasideSlotCount = -1,
create = {
it.execSQL("create table person (id integer primary key autoincrement, name text, age integer)")
},
Expand Down
4 changes: 2 additions & 2 deletions sqllin-driver/README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ val databaseConnection = openDatabase(
journalMode = JournalMode.WAL,
synchronousMode = SynchronousMode.NORMAL,
busyTimeout = 5000,
lookasideSlotSize = 0,
lookasideSlotCount = 0,
lookasideSlotSize = -1,
lookasideSlotCount = -1,
create = {
it.execSQL("create table person (id integer primary key autoincrement, name text, age integer)")
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ package com.ctrip.sqllin.driver.test

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.ctrip.sqllin.driver.DatabaseConfiguration
import com.ctrip.sqllin.driver.toAndroidOpenParams
import com.ctrip.sqllin.driver.toDatabasePath
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals

/**
* Android unit test that runs on the JVM via Robolectric. The `sdk` levels cover both
Expand Down Expand Up @@ -57,9 +60,32 @@ class AndroidTest {
@Test
fun testFailedCommit() = commonTest.testFailedCommit()

@Test
fun testRolledBackBySQLite() = commonTest.testRolledBackBySQLite()

@Test
fun testImmediateTransaction() = commonTest.testImmediateTransaction()

@Test
fun testBusyTimeout() = commonTest.testBusyTimeout()

@Test
fun testConcurrency() = commonTest.testConcurrency()

/**
* The lookaside configuration is only given to the framework when one is set, as `setLookasideConfig(0, 0)`
* disables the allocator.
*/
@Test
@Config(sdk = [37])
fun testLookasideConfig() {
val config = DatabaseConfiguration(name = SQL.DATABASE_NAME, path = context.toDatabasePath(), version = 1)
fun lookaside(config: DatabaseConfiguration) = config.toAndroidOpenParams().run { lookasideSlotSize to lookasideSlotCount }
assertEquals(-1 to -1, lookaside(config))
assertEquals(0 to 0, lookaside(config.copy(lookasideSlotSize = 0, lookasideSlotCount = 0)))
assertEquals(256 to 32, lookaside(config.copy(lookasideSlotSize = 256, lookasideSlotCount = 32)))
}

@AfterTest
fun setDown() {
context.deleteDatabase(SQL.DATABASE_NAME)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ internal class AndroidDatabaseConnection(private val database: SQLiteDatabase) :
}
}

override fun beginTransaction() = database.beginTransaction()
// BEGIN IMMEDIATE, as on every platform, which beginTransaction's EXCLUSIVE only is in WAL mode
override fun beginTransaction() = database.beginTransactionNonExclusive()
override fun endTransaction() {
try {
database.endTransaction()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ internal value class AndroidDatabasePath(val context: Context) : DatabasePath
public actual fun openDatabase(config: DatabaseConfiguration): DatabaseConnection {
val isEqualsOrHigherThanAndroidP = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
if (isEqualsOrHigherThanAndroidP && config.inMemory)
return AndroidDatabaseConnection(createInMemory(config.toAndroidOpenParams()))
return AndroidDatabaseConnection(createInMemory(config.toAndroidOpenParams()).apply { setBusyTimeout(config.busyTimeout) })
val helper = if (isEqualsOrHigherThanAndroidP)
AndroidDBHelper(config)
else
Expand All @@ -61,6 +61,8 @@ private class OldAndroidDBHelper(
private val config: DatabaseConfiguration,
) : SQLiteOpenHelper((config.path as AndroidDatabasePath).context, config.name, null, config.version) {

override fun onConfigure(db: SQLiteDatabase) = db.setBusyTimeout(config.busyTimeout)

override fun onCreate(db: SQLiteDatabase) =
config.create(AndroidDatabaseConnection(db))

Expand All @@ -76,6 +78,8 @@ private class AndroidDBHelper(
private val config: DatabaseConfiguration,
) : SQLiteOpenHelper((config.path as AndroidDatabasePath).context, config.name, config.version, config.toAndroidOpenParams()) {

override fun onConfigure(db: SQLiteDatabase) = db.setBusyTimeout(config.busyTimeout)

override fun onCreate(db: SQLiteDatabase) =
config.create(AndroidDatabaseConnection(db))

Expand All @@ -85,17 +89,36 @@ private class AndroidDBHelper(

/**
* Converts [DatabaseConfiguration] to Android's OpenParams for API level 28+.
*
* The lookaside configuration is only set when both of its values are 0 or more, as `setLookasideConfig(0, 0)`
* disables the allocator, and leaving it unset keeps SQLite's default configuration.
*/
@RequiresApi(Build.VERSION_CODES.P)
@Suppress("DEPRECATION")
private fun DatabaseConfiguration.toAndroidOpenParams(): OpenParams = OpenParams
internal fun DatabaseConfiguration.toAndroidOpenParams(): OpenParams = OpenParams
.Builder()
.setJournalMode(journalMode.name)
.setSynchronousMode(synchronousMode.name)
.setIdleConnectionTimeout(busyTimeout.toLong())
.setLookasideConfig(lookasideSlotSize, lookasideSlotCount)
.apply {
if (lookasideSlotSize >= 0 && lookasideSlotCount >= 0)
setLookasideConfig(lookasideSlotSize, lookasideSlotCount)
}
.build()

/**
* Sets how long SQLite waits for a lock that another connection holds, which the framework sets to 2500 ms.
*
* From Android 13 on, a database in WAL mode reads through several connections, so the PRAGMA is run on each of them,
* and on those opened later; the framework only runs a PRAGMA that returns a row, as this one does, from Android 13 on.
* Before, a database that SQLlin opens has a single connection.
*/
private fun SQLiteDatabase.setBusyTimeout(milliseconds: Int) {
val sql = "PRAGMA busy_timeout = $milliseconds"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
execPerConnectionSQL(sql, null)
else
rawQuery(sql, null).use { it.moveToFirst() }
}

public actual fun deleteDatabase(path: DatabasePath, name: String): Boolean =
(path as? AndroidDatabasePath)?.context?.deleteDatabase(name)
?: throw IllegalArgumentException("Please use the `Context.toDatabasePath()` to get the DatabasePath")
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ package com.ctrip.sqllin.driver
* @property journalMode The SQLite journal mode. Default: [JournalMode.WAL]
* @property synchronousMode The SQLite synchronous mode. Default: [SynchronousMode.NORMAL]
* @property busyTimeout Timeout in milliseconds for database lock waits. Default: 5000ms
* @property lookasideSlotSize Size of each lookaside memory slot. Default: 0 (use SQLite default)
* @property lookasideSlotCount Number of lookaside memory slots. Default: 0 (use SQLite default)
* @property lookasideSlotSize The size in bytes of each slot of SQLite's lookaside memory allocator, set together with
* [lookasideSlotCount] when both are 0 or more. Default: -1, which leaves SQLite's default configuration; 0 disables the
* allocator. Android 9 and higher take it when both are positive or both 0, and lower versions don't take it; on the
* JVM, where sqlite-jdbc can't set it, it has no effect
* @property lookasideSlotCount The number of slots of SQLite's lookaside memory allocator of each connection, set
* together with [lookasideSlotSize]. Default: -1, which leaves SQLite's default configuration; 0 disables the allocator
* @property create Callback invoked when creating a new database
* @property upgrade Callback invoked when upgrading the database schema
*
Expand All @@ -43,8 +47,8 @@ public data class DatabaseConfiguration(
val journalMode: JournalMode = JournalMode.WAL,
val synchronousMode: SynchronousMode = SynchronousMode.NORMAL,
val busyTimeout: Int = 5000,
val lookasideSlotSize: Int = 0,
val lookasideSlotCount: Int = 0,
val lookasideSlotSize: Int = -1,
val lookasideSlotCount: Int = -1,
val create: (databaseConnection: DatabaseConnection) -> Unit = {},
val upgrade: (databaseConnection: DatabaseConnection, oldVersion: Int, newVersion: Int) -> Unit = { _, _, _ -> },
)
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,29 @@ public inline fun <T> openDatabase(config: DatabaseConfiguration, block: (Databa
* The transaction is committed if the block completes successfully,
* or rolled back if an exception is thrown.
*
* When the block throws, a failure to end the transaction is added to its exception, rather than taking its place:
* SQLite rolls some transactions back itself, as for a constraint with ON CONFLICT ROLLBACK or a trigger's
* RAISE(ROLLBACK), and then the ROLLBACK fails, as no transaction is active.
*
* @param block The block to execute within the transaction
* @return The result of the block
*/
public inline fun <T> DatabaseConnection.withTransaction(block: (DatabaseConnection) -> T): T {
beginTransaction()
var failure: Throwable? = null
try {
val result = block(this)
setTransactionSuccessful()
return result
} catch (e: Throwable) {
failure = e
throw e
} finally {
endTransaction()
try {
endTransaction()
} catch (endFailure: Throwable) {
(failure ?: throw endFailure).addSuppressed(endFailure)
}
}
}

Expand Down
Loading
Loading