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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ libraries.

It works with any of the available implementations of AndroidX SQLite; see their documentation for more information.

> [!IMPORTANT]
> If you are using the Bundled or Native implementation, and there will be multithreaded access to the database,
> then you **must** create the driver with the `SQLITE_OPEN_FULLMUTEX` flag:
>
> ```kotlin
> Database(
> AndroidxSqliteDriver(
> createConnection = { name ->
> val openFlags = SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE or SQLITE_OPEN_FULLMUTEX
> BundledSQLiteDriver().open(name, openFlags)
> },
> ...
> )
> )
> ```
>
> If you are certain that there won't be any multithreaded access to the database,
> you can choose to omit `SQLITE_OPEN_FULLMUTEX`, and pass `isAccessMultithreaded = false`
> to `AndroidxSqliteDriver` for a (very) small performance boost.

## Gradle

```kotlin
Expand Down
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ detekt = "1.23.8"
dokka = "2.0.0"

kotlin = "2.1.10"
kotlinx-coroutines = "1.10.1"

ktlint = "1.5.0"

Expand All @@ -44,6 +45,8 @@ buildscript-publish = { module = "com.vanniktech:gradle-maven-publish-plugin", v

cashapp-sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "cashapp-sqldelight" }

kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }

# not actually used; just here so renovate picks it up
ktlint = { module = "com.pinterest.ktlint:ktlint-bom", version.ref = "ktlint" }

Expand All @@ -53,4 +56,5 @@ test-androidx-core = "androidx.test:core:1.6.1"
test-junit = { module = "junit:junit", version = "4.13.2" }
test-kotlin = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
test-kotlin-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
test-kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
test-robolectric = { module = "org.robolectric:robolectric", version = "4.14.1" }
3 changes: 3 additions & 0 deletions library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ kotlin {
}

commonTest.dependencies {
implementation(libs.kotlinx.coroutines.core)

implementation(libs.test.kotlin)
implementation(libs.test.kotlinx.coroutines)
}

jvmTest.dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteDriver
import androidx.sqlite.driver.AndroidSQLiteDriver
import app.cash.sqldelight.Transacter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import org.junit.Assert
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.util.concurrent.Semaphore

@RunWith(RobolectricTestRunner::class)
actual class CommonCallbackTest : AndroidxSqliteCallbackTest() {
override fun deleteDbFile(filename: String) {
File(filename).delete()
}
}
actual class CommonCallbackTest : AndroidxSqliteCallbackTest()

@RunWith(RobolectricTestRunner::class)
actual class CommonConcurrencyTest : AndroidxSqliteConcurrencyTest()

@RunWith(RobolectricTestRunner::class)
actual class CommonDriverTest : AndroidxSqliteDriverTest()
Expand All @@ -30,18 +31,21 @@ actual class CommonQueryTest : AndroidxSqliteQueryTest()
actual class CommonTransacterTest : AndroidxSqliteTransacterTest()

@RunWith(RobolectricTestRunner::class)
actual class CommonEphemeralTest : AndroidxSqliteEphemeralTest() {
override fun deleteDbFile(filename: String) {
File(filename).delete()
}
}
actual class CommonEphemeralTest : AndroidxSqliteEphemeralTest()

actual fun androidxSqliteTestDriver(): SQLiteDriver = AndroidSQLiteDriver()

actual fun androidxSqliteTestCreateConnection(): (String) -> SQLiteConnection = { name ->
AndroidSQLiteDriver().open(name)
}

@Suppress("InjectDispatcher")
actual val IoDispatcher: CoroutineDispatcher get() = Dispatchers.IO

actual fun deleteFile(name: String) {
File(name).delete()
}

actual inline fun <T> assertChecksThreadConfinement(
transacter: Transacter,
crossinline scope: Transacter.(T.() -> Unit) -> Unit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import androidx.sqlite.SQLiteStatement
import androidx.sqlite.execSQL
import app.cash.sqldelight.Query
import app.cash.sqldelight.Transacter
import app.cash.sqldelight.TransacterImpl
import app.cash.sqldelight.db.AfterVersion
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlCursor
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlPreparedStatement
import app.cash.sqldelight.db.SqlSchema
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.locks.ReentrantLock
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized

Expand All @@ -37,7 +39,9 @@ public class AndroidxSqliteDriver(
databaseType: AndroidxSqliteDatabaseType,
private val schema: SqlSchema<QueryResult.Value<Unit>>,
cacheSize: Int = DEFAULT_CACHE_SIZE,
private val isAccessMultithreaded: Boolean = true,
private val migrateEmptySchema: Boolean = false,
private val onConfigure: ConfigurableDatabase.() -> Unit = {},
private val onCreate: SqlDriver.() -> Unit = {},
private val onUpdate: SqlDriver.(Long, Long) -> Unit = { _, _ -> },
private val onOpen: SqlDriver.() -> Unit = {},
Expand All @@ -48,17 +52,21 @@ public class AndroidxSqliteDriver(
databaseType: AndroidxSqliteDatabaseType,
schema: SqlSchema<QueryResult.Value<Unit>>,
cacheSize: Int = DEFAULT_CACHE_SIZE,
isAccessMultithreaded: Boolean = true,
migrateEmptySchema: Boolean = false,
onConfigure: ConfigurableDatabase.() -> Unit = {},
onCreate: SqlDriver.() -> Unit = {},
onUpdate: SqlDriver.(Long, Long) -> Unit = { _, _ -> },
onOpen: SqlDriver.() -> Unit = {},
vararg migrationCallbacks: AfterVersion,
) : this(
createConnection = driver::open,
databaseType = databaseType,
cacheSize = cacheSize,
schema = schema,
cacheSize = cacheSize,
isAccessMultithreaded = isAccessMultithreaded,
migrateEmptySchema = migrateEmptySchema,
onConfigure = onConfigure,
onCreate = onCreate,
onUpdate = onUpdate,
onOpen = onOpen,
Expand All @@ -68,7 +76,6 @@ public class AndroidxSqliteDriver(
@Suppress("NonBooleanPropertyPrefixedWithIs")
private val isFirstInteraction = atomic(true)

private val transactions = TransactionsThreadLocal()
private val connection by lazy {
createConnection(
when(databaseType) {
Expand All @@ -79,6 +86,9 @@ public class AndroidxSqliteDriver(
)
}

private val transactions = TransactionsThreadLocal()
private val explicitTransactionLock = ReentrantLock()

private val statements = object : LruCache<Int, AndroidxStatement>(cacheSize) {
override fun entryRemoved(
evicted: Boolean,
Expand All @@ -90,6 +100,8 @@ public class AndroidxSqliteDriver(
}
}

private var skipStatementsCache = true

private val listenersLock = SynchronizedObject()
private val listeners = linkedMapOf<String, MutableSet<Query.Listener>>()

Expand Down Expand Up @@ -120,13 +132,14 @@ public class AndroidxSqliteDriver(
}

override fun newTransaction(): QueryResult<Transacter.Transaction> {
triggerCallbackForFirstInteraction()
createOrMigrateIfNeeded()

val enclosing = transactions.get()
val transaction = Transaction(enclosing)
transactions.set(transaction)

if(enclosing == null) {
if(isAccessMultithreaded) explicitTransactionLock.lock()
connection.execSQL("BEGIN IMMEDIATE")
}

Expand All @@ -140,10 +153,15 @@ public class AndroidxSqliteDriver(
) : Transacter.Transaction() {
override fun endTransaction(successful: Boolean): QueryResult<Unit> {
if(enclosingTransaction == null) {
if(successful) {
connection.execSQL("COMMIT")
} else {
connection.execSQL("ROLLBACK")
try {
if(successful) {
connection.execSQL("COMMIT")
} else {
connection.execSQL("ROLLBACK")
}
}
finally {
if(isAccessMultithreaded) explicitTransactionLock.unlock()
}
}
transactions.set(enclosingTransaction)
Expand All @@ -157,10 +175,10 @@ public class AndroidxSqliteDriver(
binders: (SqlPreparedStatement.() -> Unit)?,
result: AndroidxStatement.() -> T,
): QueryResult.Value<T> {
triggerCallbackForFirstInteraction()
createOrMigrateIfNeeded()

var statement: AndroidxStatement? = null
if(identifier != null) {
if(identifier != null && !skipStatementsCache) {
statement = statements[identifier]

// remove temporarily from the cache
Expand All @@ -177,7 +195,7 @@ public class AndroidxSqliteDriver(
}
return QueryResult.Value(statement.result())
} finally {
if(identifier != null) {
if(identifier != null && !skipStatementsCache) {
statements.put(identifier, statement)?.close()
statement.reset()
} else {
Expand Down Expand Up @@ -209,26 +227,49 @@ public class AndroidxSqliteDriver(
return connection.close()
}

private fun triggerCallbackForFirstInteraction() {
if(isFirstInteraction.compareAndSet(expect = true, update = false)) {
val currentVersion = connection.prepare("PRAGMA user_version").use { getVersion ->
when {
getVersion.step() -> getVersion.getLong(0)
else -> 0
private val createOrMigrateLock = SynchronizedObject()
private var isNestedUnderCreateOrMigrate = false
private fun createOrMigrateIfNeeded() {
if(isFirstInteraction.value) {
synchronized(createOrMigrateLock) {
if(isFirstInteraction.value && !isNestedUnderCreateOrMigrate) {
isNestedUnderCreateOrMigrate = true

ConfigurableDatabase(this).onConfigure()

val currentVersion = connection.prepare("PRAGMA user_version").use { getVersion ->
when {
getVersion.step() -> getVersion.getLong(0)
else -> 0
}
}

if(currentVersion == 0L && !migrateEmptySchema || currentVersion < schema.version) {
val driver = this
val transacter = object : TransacterImpl(driver) {}

transacter.transaction {
when(currentVersion) {
0L -> schema.create(driver).value
else -> schema.migrate(driver, currentVersion, schema.version, *migrationCallbacks).value
}
skipStatementsCache = false
when(currentVersion) {
0L -> onCreate()
else -> onUpdate(currentVersion, schema.version)
}
connection.prepare("PRAGMA user_version = ${schema.version}").use { it.step() }
}
}
else {
skipStatementsCache = false
}

onOpen()

isFirstInteraction.value = false
}
}

if(currentVersion == 0L && !migrateEmptySchema) {
schema.create(this).value
connection.prepare("PRAGMA user_version = ${schema.version}").use { it.step() }
onCreate()
} else if(currentVersion < schema.version) {
schema.migrate(this, currentVersion, schema.version, *migrationCallbacks).value
connection.prepare("PRAGMA user_version = ${schema.version}").use { it.step() }
onUpdate(currentVersion, schema.version)
}

onOpen()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,56 @@
package com.eygraber.sqldelight.androidx.driver

import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlCursor
import app.cash.sqldelight.db.SqlPreparedStatement

public fun AndroidxSqliteDriver.enableForeignKeys() {
execute(null, "PRAGMA foreign_keys = ON", 0, null)
execute(null, "PRAGMA foreign_keys = ON;", 0, null)
}

public fun AndroidxSqliteDriver.disableForeignKeys() {
execute(null, "PRAGMA foreign_keys = OFF", 0, null)
execute(null, "PRAGMA foreign_keys = OFF;", 0, null)
}

public fun AndroidxSqliteDriver.enableWAL() {
execute(null, "PRAGMA journal_mode = WAL", 0, null)
execute(null, "PRAGMA journal_mode = WAL;", 0, null)
}

public fun AndroidxSqliteDriver.disableWAL() {
execute(null, "PRAGMA journal_mode = DELETE", 0, null)
execute(null, "PRAGMA journal_mode = DELETE;", 0, null)
}

public class ConfigurableDatabase(
private val driver: AndroidxSqliteDriver,
) {
public fun enableForeignKeys() {
driver.enableForeignKeys()
}

public fun disableForeignKeys() {
driver.disableForeignKeys()
}

public fun enableWAL() {
driver.enableWAL()
}

public fun disableWAL() {
driver.disableWAL()
}

public fun executePragma(
pragma: String,
parameters: Int = 0,
binders: (SqlPreparedStatement.() -> Unit)? = null,
) {
driver.execute(null, "PRAGMA $pragma;", parameters, binders)
}

public fun <T> executePragmaQuery(
pragma: String,
mapper: (SqlCursor) -> QueryResult<T>,
parameters: Int = 0,
binders: (SqlPreparedStatement.() -> Unit)? = null,
): QueryResult.Value<T> = driver.executeQuery(null, "PRAGMA $pragma;", mapper, parameters, binders)
}
Loading
Loading