Skip to content

Commit 17a2480

Browse files
authored
Assorted fixes for migrations (#112)
* Make AndroidxSqliteConfiguration immutable * Only apply pragmas to already created reader connections * Rename ConfigurableDatabase * Code style update * Some documentation updates * Remove unused import * Disable foreign keys while creating or migrating the database * Add tests for ensuring connections wait for schema creation or migration * Access statementsCache through a lock - This used to be an LruCache which is synchronized internally - Now that it's a HashMap we need to synchronize it ourselves * Add tests for foreign key behavior during creation/migration * Fix migrateEmptySchema
1 parent 4bb12b5 commit 17a2480

File tree

14 files changed

+761
-69
lines changed

14 files changed

+761
-69
lines changed

.idea/codeStyles/Project.xml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,29 @@ Database(
8080

8181
It will handle calling the `create` and `migrate` functions on your schema for you, and keep track of the database's version.
8282

83+
## Foreign Key Constraints
84+
85+
When using `AndroidxSqliteDriver`, the handling of foreign key constraints during database creation and migration is
86+
managed to ensure data integrity.
87+
88+
If you have foreign key constraints enabled in your
89+
`AndroidxSqliteConfiguration` (i.e. `isForeignKeyConstraintsEnabled = true`),
90+
the driver will automatically disable them before executing the schema `create` or `migrate` operations.
91+
This is done to prevent issues with table creation order and data manipulation during the migration process.
92+
93+
After the creation or migration is complete, foreign key constraints are re-enabled.
94+
95+
Furthermore, to verify the integrity of the foreign key relationships after these operations,
96+
the driver performs an additional check. If `isForeignKeyConstraintsCheckedAfterCreateOrUpdate`
97+
is `true` (which it is by default), a `PRAGMA foreign_key_check` is executed. If this check finds
98+
any violations, an `AndroidxSqliteDriver.ForeignKeyConstraintCheckException` is thrown, detailing the
99+
specific constraints that have been violated. This helps catch any inconsistencies in your data that might
100+
have been introduced during the migration.
101+
83102
## Connection Pooling
84103

85-
By default, one connection will be used for both reading and writing, and only one thread can acquire that connection at a time.
86-
If you have WAL enabled, you could (and should) set the amount of pooled reader connections that will be used:
104+
By default, one connection will be used for both reading and writing, and only one thread can acquire that connection
105+
at a time. If you have WAL enabled, you could (and should) set the amount of pooled reader connections that will be used:
87106

88107
```kotlin
89108
AndroidxSqliteDriver(

library/build.gradle.kts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import com.android.build.api.dsl.ManagedVirtualDevice
2-
31
plugins {
42
id("com.eygraber.conventions-kotlin-multiplatform")
53
id("com.eygraber.conventions-android-library")

library/src/androidUnitTest/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteCommonTests.android.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,16 @@ actual class CommonDriverTest : AndroidxSqliteDriverTest()
4242
actual class CommonDriverOpenFlagsTest : AndroidxSqliteDriverOpenFlagsTest()
4343

4444
@RunWith(RobolectricTestRunner::class)
45-
actual class CommonQueryTest : AndroidxSqliteQueryTest()
45+
actual class CommonEphemeralTest : AndroidxSqliteEphemeralTest()
4646

4747
@RunWith(RobolectricTestRunner::class)
48-
actual class CommonTransacterTest : AndroidxSqliteTransacterTest()
48+
actual class CommonMigrationKeyTest : AndroidxSqliteMigrationKeyTest()
4949

5050
@RunWith(RobolectricTestRunner::class)
51-
actual class CommonEphemeralTest : AndroidxSqliteEphemeralTest()
51+
actual class CommonQueryTest : AndroidxSqliteQueryTest()
52+
53+
@RunWith(RobolectricTestRunner::class)
54+
actual class CommonTransacterTest : AndroidxSqliteTransacterTest()
5255

5356
actual fun androidxSqliteTestDriver(): SQLiteDriver = AndroidSQLiteDriver()
5457

library/src/commonMain/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteHelpers.kt renamed to library/src/commonMain/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteConfigurableDriver.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import app.cash.sqldelight.db.QueryResult
44
import app.cash.sqldelight.db.SqlCursor
55
import app.cash.sqldelight.db.SqlPreparedStatement
66

7-
public class ConfigurableDatabase(
7+
public class AndroidxSqliteConfigurableDriver(
88
private val driver: AndroidxSqliteDriver,
99
) {
1010
public fun setForeignKeyConstraintsEnabled(isForeignKeyConstraintsEnabled: Boolean) {

library/src/commonMain/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteConfiguration.kt

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public enum class SqliteJournalMode(internal val value: String) {
88
Truncate("TRUNCATE"),
99
Persist("PERSIST"),
1010
Memory("MEMORY"),
11+
1112
@Suppress("EnumNaming")
1213
WAL("WAL"),
1314
Off("OFF"),
@@ -35,19 +36,32 @@ public class AndroidxSqliteConfiguration(
3536
*
3637
* Default is false.
3738
*/
38-
public var isForeignKeyConstraintsEnabled: Boolean = false,
39+
public val isForeignKeyConstraintsEnabled: Boolean = false,
40+
/**
41+
* When true, a `PRAGMA foreign_key_check` is performed after the schema is created or migrated.
42+
*
43+
* This is only useful when [isForeignKeyConstraintsEnabled] is true.
44+
*
45+
* During schema creation and migration, foreign key constraints are temporarily disabled.
46+
* This check ensures that after the schema operations are complete, all foreign key constraints are satisfied.
47+
* If any violations are found, a [AndroidxSqliteDriver.ForeignKeyConstraintCheckException]
48+
* is thrown with details about the violations.
49+
*
50+
* Default is true.
51+
*/
52+
public val isForeignKeyConstraintsCheckedAfterCreateOrUpdate: Boolean = true,
3953
/**
4054
* Journal mode to use.
4155
*
4256
* Default is [SqliteJournalMode.WAL].
4357
*/
44-
public var journalMode: SqliteJournalMode = SqliteJournalMode.WAL,
58+
public val journalMode: SqliteJournalMode = SqliteJournalMode.WAL,
4559
/**
4660
* Synchronous mode to use.
4761
*
4862
* Default is [SqliteSync.Full] unless [journalMode] is set to [SqliteJournalMode.WAL] in which case it is [SqliteSync.Normal].
4963
*/
50-
public var sync: SqliteSync = when(journalMode) {
64+
public val sync: SqliteSync = when(journalMode) {
5165
SqliteJournalMode.WAL -> SqliteSync.Normal
5266
SqliteJournalMode.Delete,
5367
SqliteJournalMode.Truncate,
@@ -67,4 +81,18 @@ public class AndroidxSqliteConfiguration(
6781
SqliteJournalMode.WAL -> 4
6882
else -> 0
6983
},
70-
)
84+
) {
85+
public fun copy(
86+
isForeignKeyConstraintsEnabled: Boolean = this.isForeignKeyConstraintsEnabled,
87+
journalMode: SqliteJournalMode = this.journalMode,
88+
sync: SqliteSync = this.sync,
89+
): AndroidxSqliteConfiguration =
90+
AndroidxSqliteConfiguration(
91+
cacheSize = cacheSize,
92+
isForeignKeyConstraintsEnabled = isForeignKeyConstraintsEnabled,
93+
isForeignKeyConstraintsCheckedAfterCreateOrUpdate = isForeignKeyConstraintsCheckedAfterCreateOrUpdate,
94+
journalMode = journalMode,
95+
sync = sync,
96+
readerConnectionsCount = readerConnectionsCount,
97+
)
98+
}

library/src/commonMain/kotlin/com/eygraber/sqldelight/androidx/driver/AndroidxSqliteDriver.kt

Lines changed: 106 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import app.cash.sqldelight.db.SqlDriver
1515
import app.cash.sqldelight.db.SqlPreparedStatement
1616
import app.cash.sqldelight.db.SqlSchema
1717
import kotlinx.atomicfu.atomic
18+
import kotlinx.atomicfu.locks.ReentrantLock
1819
import kotlinx.atomicfu.locks.SynchronizedObject
1920
import kotlinx.atomicfu.locks.synchronized
21+
import kotlinx.atomicfu.locks.withLock
2022

2123
internal expect class TransactionsThreadLocal() {
2224
internal fun get(): Transacter.Transaction?
@@ -37,7 +39,17 @@ public class AndroidxSqliteDriver(
3739
private val schema: SqlSchema<QueryResult.Value<Unit>>,
3840
private val configuration: AndroidxSqliteConfiguration = AndroidxSqliteConfiguration(),
3941
private val migrateEmptySchema: Boolean = false,
40-
private val onConfigure: ConfigurableDatabase.() -> Unit = {},
42+
/**
43+
* A callback to configure the database connection when it's first opened.
44+
*
45+
* This lambda is invoked on the first interaction with the database, immediately before the schema
46+
* is created or migrated. It provides an [AndroidxSqliteConfigurableDriver] as its receiver
47+
* to allow for safe configuration of connection properties like journal mode or foreign key
48+
* constraints.
49+
*
50+
* **Warning:** The [AndroidxSqliteConfigurableDriver] receiver is ephemeral and **must not** escape the callback.
51+
*/
52+
private val onConfigure: AndroidxSqliteConfigurableDriver.() -> Unit = {},
4153
private val onCreate: AndroidxSqliteDriver.() -> Unit = {},
4254
private val onUpdate: AndroidxSqliteDriver.(Long, Long) -> Unit = { _, _ -> },
4355
private val onOpen: AndroidxSqliteDriver.() -> Unit = {},
@@ -50,7 +62,17 @@ public class AndroidxSqliteDriver(
5062
schema: SqlSchema<QueryResult.Value<Unit>>,
5163
configuration: AndroidxSqliteConfiguration = AndroidxSqliteConfiguration(),
5264
migrateEmptySchema: Boolean = false,
53-
onConfigure: ConfigurableDatabase.() -> Unit = {},
65+
/**
66+
* A callback to configure the database connection when it's first opened.
67+
*
68+
* This lambda is invoked on the first interaction with the database, immediately before the schema
69+
* is created or migrated. It provides an [AndroidxSqliteConfigurableDriver] as its receiver
70+
* to allow for safe configuration of connection properties like journal mode or foreign key
71+
* constraints.
72+
*
73+
* **Warning:** The [AndroidxSqliteConfigurableDriver] receiver is ephemeral and **must not** escape the callback.
74+
*/
75+
onConfigure: AndroidxSqliteConfigurableDriver.() -> Unit = {},
5476
onCreate: SqlDriver.() -> Unit = {},
5577
onUpdate: SqlDriver.(Long, Long) -> Unit = { _, _ -> },
5678
onOpen: SqlDriver.() -> Unit = {},
@@ -70,6 +92,8 @@ public class AndroidxSqliteDriver(
7092
migrationCallbacks = migrationCallbacks,
7193
)
7294

95+
public class ForeignKeyConstraintCheckException(message: String) : Exception(message)
96+
7397
@Suppress("NonBooleanPropertyPrefixedWithIs")
7498
private val isFirstInteraction = atomic(true)
7599

@@ -102,24 +126,27 @@ public class AndroidxSqliteDriver(
102126
private val transactions = TransactionsThreadLocal()
103127

104128
private val statementsCache = HashMap<SQLiteConnection, LruCache<Int, AndroidxStatement>>()
129+
private val statementsCacheLock = ReentrantLock()
105130

106131
private fun getStatementCache(connection: SQLiteConnection) =
107-
when {
108-
configuration.cacheSize > 0 ->
109-
statementsCache.getOrPut(connection) {
110-
object : LruCache<Int, AndroidxStatement>(configuration.cacheSize) {
111-
override fun entryRemoved(
112-
evicted: Boolean,
113-
key: Int,
114-
oldValue: AndroidxStatement,
115-
newValue: AndroidxStatement?,
116-
) {
117-
if(evicted) oldValue.close()
132+
statementsCacheLock.withLock {
133+
when {
134+
configuration.cacheSize > 0 ->
135+
statementsCache.getOrPut(connection) {
136+
object : LruCache<Int, AndroidxStatement>(configuration.cacheSize) {
137+
override fun entryRemoved(
138+
evicted: Boolean,
139+
key: Int,
140+
oldValue: AndroidxStatement,
141+
newValue: AndroidxStatement?,
142+
) {
143+
if(evicted) oldValue.close()
144+
}
118145
}
119146
}
120-
}
121147

122-
else -> null
148+
else -> null
149+
}
123150
}
124151

125152
private var skipStatementsCache = true
@@ -132,7 +159,7 @@ public class AndroidxSqliteDriver(
132159
/**
133160
* True if foreign key constraints are enabled.
134161
*
135-
* This function will block until all connections have been updated.
162+
* This function will block until all created connections have been updated.
136163
*
137164
* An exception will be thrown if this is called from within a transaction.
138165
*/
@@ -147,7 +174,7 @@ public class AndroidxSqliteDriver(
147174
/**
148175
* Journal mode to use.
149176
*
150-
* This function will block until all connections have been updated.
177+
* This function will block until all created connections have been updated.
151178
*
152179
* An exception will be thrown if this is called from within a transaction.
153180
*/
@@ -162,7 +189,7 @@ public class AndroidxSqliteDriver(
162189
/**
163190
* Synchronous mode to use.
164191
*
165-
* This function will block until all connections have been updated.
192+
* This function will block until all created connections have been updated.
166193
*
167194
* An exception will be thrown if this is called from within a transaction.
168195
*/
@@ -383,7 +410,7 @@ public class AndroidxSqliteDriver(
383410
if(isFirstInteraction.value && !isNestedUnderCreateOrMigrate) {
384411
isNestedUnderCreateOrMigrate = true
385412

386-
ConfigurableDatabase(this).onConfigure()
413+
AndroidxSqliteConfigurableDriver(this).onConfigure()
387414

388415
val writerConnection = connectionPool.acquireWriterConnection()
389416
val currentVersion = try {
@@ -397,21 +424,24 @@ public class AndroidxSqliteDriver(
397424
connectionPool.releaseWriterConnection()
398425
}
399426

400-
if(currentVersion == 0L && !migrateEmptySchema || currentVersion < schema.version) {
427+
val isCreate = currentVersion == 0L && !migrateEmptySchema
428+
if(isCreate || currentVersion < schema.version) {
401429
val driver = this
402430
val transacter = object : TransacterImpl(driver) {}
403431

404-
transacter.transaction {
405-
when(currentVersion) {
406-
0L -> schema.create(driver).value
407-
else -> schema.migrate(driver, currentVersion, schema.version, *migrationCallbacks).value
408-
}
409-
skipStatementsCache = configuration.cacheSize == 0
410-
when(currentVersion) {
411-
0L -> onCreate()
412-
else -> onUpdate(currentVersion, schema.version)
432+
writerConnection.withDeferredForeignKeyChecks(configuration) {
433+
transacter.transaction {
434+
when {
435+
isCreate -> schema.create(driver).value
436+
else -> schema.migrate(driver, currentVersion, schema.version, *migrationCallbacks).value
437+
}
438+
skipStatementsCache = configuration.cacheSize == 0
439+
when {
440+
isCreate -> onCreate()
441+
else -> onUpdate(currentVersion, schema.version)
442+
}
443+
writerConnection.prepare("PRAGMA user_version = ${schema.version}").use { it.step() }
413444
}
414-
writerConnection.prepare("PRAGMA user_version = ${schema.version}").use { it.step() }
415445
}
416446
} else {
417447
skipStatementsCache = configuration.cacheSize == 0
@@ -426,6 +456,52 @@ public class AndroidxSqliteDriver(
426456
}
427457
}
428458

459+
private inline fun SQLiteConnection.withDeferredForeignKeyChecks(
460+
configuration: AndroidxSqliteConfiguration,
461+
block: () -> Unit,
462+
) {
463+
if(configuration.isForeignKeyConstraintsEnabled) {
464+
prepare("PRAGMA foreign_keys = OFF;").use(SQLiteStatement::step)
465+
}
466+
467+
block()
468+
469+
if(configuration.isForeignKeyConstraintsEnabled) {
470+
prepare("PRAGMA foreign_keys = ON;").use(SQLiteStatement::step)
471+
472+
if(configuration.isForeignKeyConstraintsCheckedAfterCreateOrUpdate) {
473+
prepare("PRAGMA foreign_key_check;").use { check ->
474+
val violations = mutableListOf<String>()
475+
while(check.step()) {
476+
val referencingTable = check.getText(0)
477+
val referencingRowId = check.getInt(1)
478+
val referencedTable = check.getText(2)
479+
val referencingConstraintIndex = check.getInt(3)
480+
481+
violations.add(
482+
"""
483+
|Constraint index: $referencingConstraintIndex
484+
|Referencing table: $referencingTable
485+
|Referencing rowId: $referencingRowId
486+
|Referenced table: $referencedTable
487+
""".trimMargin(),
488+
)
489+
}
490+
491+
if(violations.isNotEmpty()) {
492+
throw AndroidxSqliteDriver.ForeignKeyConstraintCheckException(
493+
"""
494+
|The following foreign key constraints are violated:
495+
|
496+
|${violations.joinToString(separator = "\n\n")}
497+
""".trimMargin(),
498+
)
499+
}
500+
}
501+
}
502+
}
503+
}
504+
429505
internal interface AndroidxStatement : SqlPreparedStatement {
430506
fun execute(): Long
431507
fun <R> executeQuery(mapper: (SqlCursor) -> QueryResult<R>): R

0 commit comments

Comments
 (0)