Migrate Room to Kotlin Multiplatform

This document describes how to migrate an existing Room implementation to one that uses Kotlin Multiplatform (KMP).

Migrating Room usages in an existing Android codebase to a common shared KMP module can vary widely in difficulty depending on the Room APIs used or if the codebase already uses Coroutines. This section offers some guidance and tips when trying to migrate usages of Room to a common module.

It is important to first familiarize yourself with the differences and missing features between the Android version of Room and the KMP version along with the setup involved. In essence, a successful migration involves refactoring usages of the SupportSQLite* APIs and replacing them with SQLite Driver APIs along with moving Room declarations (@Database annotated class, DAOs, entities, and so on) into common code.

Revisit the following information before continuing:

The next sections describe the various steps required for a successful migration.

Migrate from Support SQLite to SQLite Driver

The APIs in androidx.sqlite.db are Android-only, and any usages need to be refactored with SQLite Driver APIs. For backwards compatibility, and as long as the RoomDatabase is configured with a SupportSQLiteOpenHelper.Factory (i.e. no SQLiteDriver is set), then Room behaves in 'compatibility mode' where both Support SQLite and SQLite Driver APIs work as expected. This enables incremental migrations so that you don't need to convert all your Support SQLite usages to SQLite Driver in a single change.

The following examples are common usages of Support SQLite and their SQLite Driver counterparts:

Support SQLite (from)

Execute a query with no result

val database: SupportSQLiteDatabase = ...
database.execSQL("ALTER TABLE ...")

Execute a query with result but no arguments

val database: SupportSQLiteDatabase = ...
database.query("SELECT * FROM Pet").use { cursor ->
  while (cusor.moveToNext()) {
    // read columns
    cursor.getInt(0)
    cursor.getString(1)
  }
}

Execute a query with result and arguments

database.query("SELECT * FROM Pet WHERE id = ?", id).use { cursor ->
  if (cursor.moveToNext()) {
    // row found, read columns
  } else {
    // row not found
  }
}

SQLite Driver (to)

Execute a query with no result

val connection: SQLiteConnection = ...
connection.execSQL("ALTER TABLE ...")

Execute a query with result but no arguments

val connection: SQLiteConnection = ...
connection.prepare("SELECT * FROM Pet").use { statement ->
  while (statement.step()) {
    // read columns
    statement.getInt(0)
    statement.getText(1)
  }
}

Execute a query with result and arguments

connection.prepare("SELECT * FROM Pet WHERE id = ?").use { statement ->
  statement.bindInt(1, id)
  if (statement.step()) {
    // row found, read columns
  } else {
    // row not found
  }
}

Database transaction APIs are available directly in SupportSQLiteDatabase with beginTransaction(), setTransactionSuccessful() and endTransaction(). They're also available through Room using runInTransaction(). Migrate these usages to SQLite Driver APIs.

Support SQLite (from)

Perform a transaction (using RoomDatabase)

val database: RoomDatabase = ...
database.runInTransaction {
  // perform database operations in transaction
}

Perform a transaction (using SupportSQLiteDatabase)

val database: SupportSQLiteDatabase = ...
database.beginTransaction()
try {
  // perform database operations in transaction
  database.setTransactionSuccessful()
} finally {
  database.endTransaction()
}

SQLite Driver (to)

Perform a transaction (using RoomDatabase)

val database: RoomDatabase = ...
database.useWriterConnection { transactor ->
  transactor.immediateTransaction {
    // perform database operations in transaction
  }
}

Perform a transaction (using SQLiteConnection)

val connection: SQLiteConnection = ...
connection.execSQL("BEGIN IMMEDIATE TRANSACTION")
try {
  // perform database operations in transaction
  connection.execSQL("END TRANSACTION")
} catch(t: Throwable) {
  connection.execSQL("ROLLBACK TRANSACTION")
}

Various callback override also need to be migrated to their driver counterparts:

Support SQLite (from)

Migration subclasses

object Migration_1_2 : Migration(1, 2) {
  override fun migrate(db: SupportSQLiteDatabase) {
    // ...
  }
}

Auto migration specification subclasses

class AutoMigrationSpec_1_2 : AutoMigrationSpec {
  override fun onPostMigrate(db: SupportSQLiteDatabase) {
    // ...
  }
}

Database callback subclasses

object MyRoomCallback : RoomDatabase.Callback {
  override fun onCreate(db: SupportSQLiteDatabase) {
    // ...
  }

  override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
    // ...
  }

  override fun onOpen(db: SupportSQLiteDatabase) {
    // ...
  }
}

SQLite Driver (to)

Migration subclasses

object Migration_1_2 : Migration(1, 2) {
  override fun migrate(connection: SQLiteConnection) {
    // ...
  }
}

Auto migration specification subclasses

class AutoMigrationSpec_1_2 : AutoMigrationSpec {
  override fun onPostMigrate(connection: SQLiteConnection) {
    // ...
  }
}

Database callback subclasses

object MyRoomCallback : RoomDatabase.Callback {
  override fun onCreate(connection: SQLiteConnection) {
    // ...
  }

  override fun onDestructiveMigration(connection: SQLiteConnection) {
    // ...
  }

  override fun onOpen(connection: SQLiteConnection) {
    // ...
  }
}

To summarize, replace usages of SQLiteDatabase, with SQLiteConnection when a RoomDatabase is not available, such as in callback overrides (onMigrate, onCreate, etc). If a RoomDatabase is available, then access the underlying database connection using RoomDatabase.useReaderConnection and RoomDatabase.useWriterConnection instead of RoomDatabase.openHelper.writtableDatabase.

Convert blocking DAO functions to suspend functions

The KMP version of Room relies on coroutines to perform I/O operations on the configured CoroutineContext. This means that you need to migrate any blocking DAO functions to suspend functions.

Blocking DAO function (from)

@Query("SELECT * FROM Todo")
fun getAllTodos(): List<Todo>

Suspending DAO function (to)

@Query("SELECT * FROM Todo")
suspend fun getAllTodos(): List<Todo>

Migrating existing DAO blocking functions to suspend functions can be complicated if the existing codebase does not already incorporate coroutines. Refer to Coroutines in Android to get started on using coroutines in your codebase.

Convert reactive return types to Flow

Not all DAO functions need to be suspend functions. DAO functions that return reactive types such as LiveData or RxJava's Flowable shouldn't be converted to suspend functions. Some types, however, such as LiveData are not KMP compatible. DAO functions with reactive return types must be migrated to coroutine flows.

Incompatible KMP type (from)

@Query("SELECT * FROM Todo")
fun getTodosLiveData(): LiveData<List<Todo>>

Compatible KMP type (to)

@Query("SELECT * FROM Todo")
fun getTodosFlow(): Flow<List<Todo>>

Refer to Flows in Android to get started on using Flows in your codebase.

Set a Coroutine context (Optional)

A RoomDatabase can optionally be configured with shared application executors using RoomDatabase.Builder.setQueryExecutor() to perform database operations. Since executors are not KMP compatible, Room's setQueryExecutor() API is not available for common sources. Instead the RoomDatabase must be configured with a CoroutineContext. A context can be set using RoomDatabase.Builder.setCoroutineContext(), if none is set then the RoomDatabase will default to using Dispatchers.IO.

Set a SQLite Driver

Once Support SQLite usages have been migrated to SQLite Driver APIs then a driver has to be configured using RoomDatabase.Builder.setDriver. The recommended driver is BundledSQLiteDriver. See Driver implementations for descriptions of available driver implementations.

Custom SupportSQLiteOpenHelper.Factory configured using RoomDatabase.Builder.openHelperFactory() are not support in KMP, the features provided by the custom open helper will need to be re-implemented with SQLite Driver interfaces.

Move Room declarations

Once most of the migrations steps are completed, one can move the Room definitions to a common source set. Note that expect / actual strategies can be used to incrementally move Room related definitions. For example, if not all blocking DAO functions can be migrated to suspend functions, it is possible to declare an expect @Dao annotated interface that is empty in common code, but contains blocking functions in Android.

// shared/src/commonMain/kotlin/Database.kt

@Database(entities = [TodoEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
  abstract fun getDao(): TodoDao
  abstract fun getBlockingDao(): BlockingTodoDao
}

@Dao
interface TodoDao {
    @Query("SELECT count(*) FROM TodoEntity")
    suspend fun count(): Int
}

@Dao
expect interface BlockingTodoDao
// shared/src/androidMain/kotlin/BlockingTodoDao.kt

@Dao
actual interface BlockingTodoDao {
    @Query("SELECT count(*) FROM TodoEntity")
    fun count(): Int
}