Skip to content

Commit b0bb84d

Browse files
committed
DataConnectCacheDatabaseMigrator.kt, DataConnectCacheDatabaseMigratorUnitTest.kt: Improve schema version error handling
Improves the developer experience by providing more detailed and helpful error messages when the database schema version is invalid or not set. This change helps prevent potential data corruption by aborting migrations on databases with unknown schema versions. ### Changes * DataConnectCacheDatabaseMigrator.kt: * Updated the exception messages for invalid or null schema versions to be more descriptive. * DataConnectCacheDatabaseMigratorUnitTest.kt: * Added unit tests to verify that an `InvalidSchemaVersionException` is thrown for unknown schema versions. * Added a unit test to verify that an `InvalidSchemaVersionException` is thrown for a null schema version.
1 parent b826a42 commit b0bb84d

File tree

2 files changed

+156
-3
lines changed

2 files changed

+156
-3
lines changed

firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/sqlite2/DataConnectCacheDatabaseMigrator.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,11 @@ private constructor(private val sqliteDatabase: SQLiteDatabase, private val logg
183183
// The PATCH version changes for all other non-schema-affecting changes, such as adding an
184184
// index to an existing table.
185185
return if (schemaVersion === null) {
186-
throw InvalidSchemaVersionException("schema_version is null")
186+
throw InvalidSchemaVersionException(
187+
"schema_version is null or not set;" +
188+
" expected a value that starts with \"1.\";" +
189+
" aborting to avoid corrupting the contents of the database"
190+
)
187191
} else if (schemaVersion == "1.0.0") {
188192
RunMigrationStepResult.StepExecuted("1.1.0").apply {
189193
logger.debug { "migrating to schema version $newSchemaVersion from $schemaVersion" }
@@ -192,7 +196,11 @@ private constructor(private val sqliteDatabase: SQLiteDatabase, private val logg
192196
} else if (schemaVersion.startsWith("1.")) {
193197
RunMigrationStepResult.NoMore
194198
} else {
195-
throw InvalidSchemaVersionException("unsupported schema_version: $schemaVersion")
199+
throw InvalidSchemaVersionException(
200+
"schema_version $schemaVersion is unknown;" +
201+
" expected a value that starts with \"1.\";" +
202+
" aborting to avoid corrupting the contents of the database"
203+
)
196204
}
197205
}
198206

firebase-dataconnect/src/test/kotlin/com/google/firebase/dataconnect/sqlite2/DataConnectCacheDatabaseMigratorUnitTest.kt

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,36 @@
1616

1717
package com.google.firebase.dataconnect.sqlite2
1818

19+
import android.database.sqlite.SQLiteDatabase
1920
import com.google.firebase.dataconnect.core.Logger
2021
import com.google.firebase.dataconnect.sqlite2.DataConnectCacheDatabaseMigrator.InvalidApplicationIdException
22+
import com.google.firebase.dataconnect.sqlite2.DataConnectCacheDatabaseMigrator.InvalidSchemaVersionException
2123
import com.google.firebase.dataconnect.sqlite2.DataConnectCacheDatabaseMigrator.InvalidUserVersionException
2224
import com.google.firebase.dataconnect.sqlite2.SQLiteDatabaseExts.getApplicationId
2325
import com.google.firebase.dataconnect.sqlite2.SQLiteDatabaseExts.setApplicationId
2426
import com.google.firebase.dataconnect.testutil.DataConnectLogLevelRule
2527
import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingText
2628
import com.google.firebase.dataconnect.testutil.shouldContainWithNonAbuttingTextIgnoringCase
2729
import com.google.firebase.dataconnect.util.StringUtil.to0xHexString
30+
import io.github.z4kn4fein.semver.toVersion
2831
import io.kotest.assertions.assertSoftly
2932
import io.kotest.assertions.throwables.shouldThrow
33+
import io.kotest.assertions.withClue
3034
import io.kotest.common.ExperimentalKotest
35+
import io.kotest.matchers.nulls.shouldNotBeNull
3136
import io.kotest.matchers.shouldBe
3237
import io.kotest.property.Arb
3338
import io.kotest.property.EdgeConfig
3439
import io.kotest.property.PropTestConfig
40+
import io.kotest.property.arbitrary.Codepoint
41+
import io.kotest.property.arbitrary.alphanumeric
42+
import io.kotest.property.arbitrary.bind
43+
import io.kotest.property.arbitrary.choice
44+
import io.kotest.property.arbitrary.filterNot
3545
import io.kotest.property.arbitrary.int
36-
import io.kotest.property.arbitrary.next
46+
import io.kotest.property.arbitrary.map
47+
import io.kotest.property.arbitrary.nonNegativeInt
48+
import io.kotest.property.arbitrary.string
3749
import io.kotest.property.assume
3850
import io.kotest.property.checkAll
3951
import io.mockk.mockk
@@ -161,6 +173,100 @@ class DataConnectCacheDatabaseMigratorUnitTest {
161173
}
162174
}
163175

176+
@Test
177+
fun `migrate() schema_version should set the value in a new database`() {
178+
val mockLogger: Logger = mockk(relaxed = true)
179+
180+
val schemaVersion =
181+
DataConnectSQLiteDatabaseOpener.open(dbFile, mockLogger).use { db ->
182+
DataConnectCacheDatabaseMigrator.migrate(db, mockLogger)
183+
getSchemaVersion(db)
184+
}
185+
186+
val parsedSchemaVersion =
187+
withClue("schemaVersion") { schemaVersion.shouldNotBeNull().toVersion() }
188+
withClue("parsedSchemaVersion.major") { parsedSchemaVersion.major shouldBe 1 }
189+
}
190+
191+
@Test
192+
fun `migrate() schema_version should leave value alone if already set`() {
193+
val mockLogger: Logger = mockk(relaxed = true)
194+
DataConnectSQLiteDatabaseOpener.open(dbFile, mockLogger).use { db ->
195+
DataConnectCacheDatabaseMigrator.migrate(db, mockLogger)
196+
val schemaVersion1 = withClue("getSchemaVersion1") { getSchemaVersion(db).shouldNotBeNull() }
197+
DataConnectCacheDatabaseMigrator.migrate(db, mockLogger)
198+
val schemaVersion2 = withClue("getSchemaVersion2") { getSchemaVersion(db).shouldNotBeNull() }
199+
schemaVersion1 shouldBe schemaVersion2
200+
}
201+
}
202+
203+
@Test
204+
fun `migrate() schema_version should throw if the schema version is invalid`() = runTest {
205+
val invalidMajorVersionArb = Arb.int(2..Int.MAX_VALUE).map { "$it.2.3" }
206+
val invalidSemanticVersionArb =
207+
Arb.string(0..10, Codepoint.alphanumeric()).filterNot { it.startsWith("1.") }
208+
val invalidSchemaVersionArb = Arb.choice(invalidMajorVersionArb, invalidSemanticVersionArb)
209+
checkAll(propTestConfig, invalidSchemaVersionArb) { schemaVersion ->
210+
val mockLogger: Logger = mockk(relaxed = true)
211+
212+
val exception =
213+
DataConnectSQLiteDatabaseOpener.open(null, mockLogger).use { db ->
214+
DataConnectCacheDatabaseMigrator.migrate(db, mockLogger)
215+
setSchemaVersion(db, schemaVersion)
216+
217+
shouldThrow<InvalidSchemaVersionException> {
218+
DataConnectCacheDatabaseMigrator.migrate(db, mockLogger)
219+
}
220+
}
221+
222+
assertSoftly {
223+
exception.message shouldContainWithNonAbuttingText "schema_version"
224+
exception.message shouldContainWithNonAbuttingText schemaVersion
225+
exception.message shouldContainWithNonAbuttingTextIgnoringCase "unknown"
226+
exception.message shouldContainWithNonAbuttingTextIgnoringCase "aborting"
227+
}
228+
}
229+
}
230+
231+
@Test
232+
fun `migrate() schema_version should throw if the schema version is not set`() {
233+
val mockLogger: Logger = mockk(relaxed = true)
234+
235+
val exception =
236+
DataConnectSQLiteDatabaseOpener.open(null, mockLogger).use { db ->
237+
DataConnectCacheDatabaseMigrator.migrate(db, mockLogger)
238+
unsetSchemaVersion(db)
239+
240+
shouldThrow<InvalidSchemaVersionException> {
241+
DataConnectCacheDatabaseMigrator.migrate(db, mockLogger)
242+
}
243+
}
244+
245+
assertSoftly {
246+
exception.message shouldContainWithNonAbuttingText "schema_version"
247+
exception.message shouldContainWithNonAbuttingTextIgnoringCase "null"
248+
exception.message shouldContainWithNonAbuttingTextIgnoringCase "aborting"
249+
}
250+
}
251+
252+
@Test
253+
fun `migrate() schema_version should accept higher minor and-or patch versions`() = runTest {
254+
val mockLogger: Logger = mockk(relaxed = true)
255+
DataConnectSQLiteDatabaseOpener.open(dbFile, mockLogger).use { db ->
256+
DataConnectCacheDatabaseMigrator.migrate(db, mockLogger)
257+
val originalSchemaVersion =
258+
withClue("getSchemaVersion") { getSchemaVersion(db).shouldNotBeNull() }
259+
checkAll(propTestConfig, higherMinorAndOrPatchVersionArb(originalSchemaVersion)) {
260+
newSchemaVersion ->
261+
setSchemaVersion(db, newSchemaVersion)
262+
263+
DataConnectCacheDatabaseMigrator.migrate(db, mockLogger)
264+
265+
getSchemaVersion(db) shouldBe newSchemaVersion
266+
}
267+
}
268+
}
269+
164270
private companion object {
165271

166272
@OptIn(ExperimentalKotest::class)
@@ -169,5 +275,44 @@ class DataConnectCacheDatabaseMigratorUnitTest {
169275
iterations = 10,
170276
edgeConfig = EdgeConfig(edgecasesGenerationProbability = 0.33)
171277
)
278+
279+
fun getSchemaVersion(db: SQLiteDatabase): String? {
280+
db.rawQuery("SELECT text FROM metadata WHERE name = 'schema_version'", null).use { cursor ->
281+
return if (cursor.moveToNext()) cursor.getString(0) else null
282+
}
283+
}
284+
285+
fun setSchemaVersion(db: SQLiteDatabase, value: String) {
286+
db.execSQL(
287+
"INSERT OR REPLACE INTO metadata (name, text) VALUES ('schema_version', ?)",
288+
arrayOf(value)
289+
)
290+
}
291+
292+
fun unsetSchemaVersion(db: SQLiteDatabase) {
293+
db.execSQL("DELETE FROM metadata WHERE name = 'schema_version'")
294+
}
295+
296+
/**
297+
* Creates and returns an [Arb] that generates whose values are semantic versions that have the
298+
* same major version as the given version, but a higher minor and/or patch version.
299+
*/
300+
fun higherMinorAndOrPatchVersionArb(version: String): Arb<String> {
301+
val parsedVersion = version.toVersion(strict = false)
302+
303+
val higherMinorVersionArb =
304+
Arb.bind(Arb.int(parsedVersion.minor + 1..Int.MAX_VALUE), Arb.nonNegativeInt()) {
305+
minor,
306+
patch ->
307+
parsedVersion.copy(minor = minor, patch = patch).toString()
308+
}
309+
310+
val higherPatchVersionArb =
311+
Arb.int(parsedVersion.patch + 1..Int.MAX_VALUE).map { patch ->
312+
parsedVersion.copy(patch = patch).toString()
313+
}
314+
315+
return Arb.choice(higherMinorVersionArb, higherPatchVersionArb)
316+
}
172317
}
173318
}

0 commit comments

Comments
 (0)