העברת מסד הנתונים של החדרים

כשמוסיפים תכונות לאפליקציה או משנים אותן, צריך לשנות את מחלקות הישויות של Room ואת טבלאות מסד הנתונים הבסיסיות כדי לשקף את השינויים האלה. חשוב לשמור את נתוני המשתמשים שכבר נמצאים במסד הנתונים במכשיר כשעדכון של אפליקציה משנה את סכימת מסד הנתונים.

‫Room תומך באפשרויות אוטומטיות וידניות להעברה מצטברת. העברות אוטומטיות פועלות ברוב השינויים הבסיסיים בסכימה, אבל יכול להיות שתצטרכו להגדיר ידנית נתיבי העברה לשינויים מורכבים יותר.

העברות אוטומטיות

כדי להצהיר על העברה אוטומטית בין שתי גרסאות של מסד נתונים, מוסיפים הערה עם האנוטציה @AutoMigration למאפיין autoMigrations ב-@Database:

Kotlin

// Database class before the version update.
@Database(
  version = 1,
  entities = [User::class]
)
abstract class AppDatabase : RoomDatabase() {
  ...
}

// Database class after the version update.
@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (from = 1, to = 2)
  ]
)
abstract class AppDatabase : RoomDatabase() {
  ...
}

Java

// Database class before the version update.
@Database(
  version = 1,
  entities = {User.class}
)
public abstract class AppDatabase extends RoomDatabase {
  ...
}

// Database class after the version update.
@Database(
  version = 2,
  entities = {User.class},
  autoMigrations = {
    @AutoMigration (from = 1, to = 2)
  }
)
public abstract class AppDatabase extends RoomDatabase {
  ...
}

מפרטים של העברה אוטומטית

אם Room מזהה שינויים לא חד-משמעיים בסכימה ולא יכול ליצור תוכנית העברה בלי קלט נוסף, הוא מציג שגיאה בזמן ההידור ומבקש להטמיע AutoMigrationSpec. בדרך כלל, זה קורה כשההעברה כוללת אחד מהמצבים הבאים:

  • מחיקה או שינוי שם של טבלה.
  • מחיקה או שינוי שם של עמודה.

אתם יכולים להשתמש ב-AutoMigrationSpec כדי לספק ל-Room את המידע הנוסף שהוא צריך כדי ליצור נתיבי העברה נכונים. מגדירים מחלקה סטטית שמטמיעה את AutoMigrationSpec במחלקה RoomDatabase ומוסיפים לה הערה עם אחת מהאפשרויות הבאות או יותר:

כדי להשתמש בהטמעה של AutoMigrationSpec להעברה אוטומטית, מגדירים את המאפיין spec בהערה המתאימה @AutoMigration:

Kotlin

@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (
      from = 1,
      to = 2,
      spec = AppDatabase.MyAutoMigration::class
    )
  ]
)
abstract class AppDatabase : RoomDatabase() {
  @RenameTable(fromTableName = "User", toTableName = "AppUser")
  class MyAutoMigration : AutoMigrationSpec
  ...
}

Java

@Database(
  version = 2,
  entities = {AppUser.class},
  autoMigrations = {
    @AutoMigration (
      from = 1,
      to = 2,
      spec = AppDatabase.MyAutoMigration.class
    )
  }
)
public abstract class AppDatabase extends RoomDatabase {
  @RenameTable(fromTableName = "User", toTableName = "AppUser")
  static class MyAutoMigration implements AutoMigrationSpec { }
  ...
}

אם האפליקציה שלכם צריכה לבצע עוד פעולות אחרי שהמיגרציה האוטומטית מסתיימת, אתם יכולים להטמיע onPostMigrate(). אם מטמיעים את השיטה הזו ב-AutoMigrationSpec, ‏ Room קורא לה אחרי שההעברה האוטומטית מסתיימת.

העברות ידניות

במקרים שבהם ההעברה כוללת שינויים מורכבים בסכימה, יכול להיות ש-Room לא יוכל ליצור באופן אוטומטי נתיב העברה מתאים. לדוגמה, אם תחליטו לפצל את הנתונים בטבלה לשתי טבלאות, Room לא יוכל לדעת איך לבצע את הפיצול הזה. במקרים כאלה, צריך להגדיר ידנית נתיב העברה באמצעות הטמעה של מחלקת Migration.

מחלקת Migration מגדירה באופן מפורש נתיב העברה בין startVersion לבין endVersion על ידי החלפת השיטה Migration.migrate(). מוסיפים את המחלקות Migration לכלי ליצירת מסד הנתונים באמצעות השיטה addMigrations():

Kotlin

val MIGRATION_1_2 = object : Migration(1, 2) {
  override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, `name` TEXT, " +
      "PRIMARY KEY(`id`))")
  }
}

val MIGRATION_2_3 = object : Migration(2, 3) {
  override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("ALTER TABLE Book ADD COLUMN pub_year INTEGER")
  }
}

Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
  .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build()

Java

static final Migration MIGRATION_1_2 = new Migration(1, 2) {
  @Override
  public void migrate(SupportSQLiteDatabase database) {
    database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, "
      + "`name` TEXT, PRIMARY KEY(`id`))");
  }
};

static final Migration MIGRATION_2_3 = new Migration(2, 3) {
  @Override
  public void migrate(SupportSQLiteDatabase database) {
    database.execSQL("ALTER TABLE Book "
      + " ADD COLUMN pub_year INTEGER");
  }
};

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
  .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build();

כשמגדירים את נתיבי ההעברה, אפשר להשתמש בהעברות אוטומטיות לחלק מהגרסאות ובהעברות ידניות לגרסאות אחרות. אם מגדירים גם העברה אוטומטית וגם העברה ידנית לאותה גרסה, Room משתמש בהעברה הידנית.

בדיקת העברות

העברות הן לרוב מורכבות, והגדרה שגויה של העברה עלולה לגרום לקריסת האפליקציה. כדי לשמור על יציבות האפליקציה, מומלץ לבדוק את ההעברות. ‫Room מספק room-testing Maven artifact כדי לעזור בתהליך הבדיקה של העברות אוטומטיות וידניות. כדי שהארטיפקט הזה יפעל, צריך קודם לייצא את הסכימה של מסד הנתונים.

ייצוא סכימות

‫Room יכול לייצא את פרטי הסכימה של מסד הנתונים לקובץ JSON בזמן ההידור. קובצי ה-JSON המיוצאים מייצגים את היסטוריית הסכימה של מסד הנתונים. כדאי לאחסן את הקבצים האלה במערכת בקרת הגרסאות, כדי שספריית Room תוכל ליצור גרסאות ישנות יותר של מסד הנתונים למטרות בדיקה וכדי לאפשר יצירה אוטומטית של העברה.

הגדרת מיקום הסכימה באמצעות Room Gradle Plugin

אם אתם משתמשים בגרסה 2.6.0 ואילך של Room, אתם יכולים להחיל את Room Gradle Plugin ולהשתמש בתוסף room כדי לציין את ספריית הסכימה.

מגניב

plugins {
  id 'androidx.room'
}

room {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room")
}

room {
  schemaDirectory("$projectDir/schemas")
}

אם סכימת מסד הנתונים שונה בהתאם לווריאציה, לטעם או לסוג הבנייה, צריך לציין מיקומים שונים באמצעות הגדרת schemaDirectory() כמה פעמים, כשכל הגדרה מתחילה ב-variantMatchName כארגומנט הראשון. כל הגדרה יכולה להתאים לווריאנט אחד או יותר על סמך השוואה פשוטה לשם הווריאנט.

חשוב לוודא שהם מקיפים ומכסים את כל הווריאציות. אפשר גם לכלול schemaDirectory() בלי variantMatchName כדי לטפל בווריאציות שלא תואמות לאף אחת מההגדרות האחרות. לדוגמה, באפליקציה עם שני טעמי build‏ demo ו-full ושני סוגי build‏ debug ו-release, ההגדרות הבאות הן הגדרות תקינות:

מגניב

room {
  // Applies to 'demoDebug' only
  schemaDirectory "demoDebug", "$projectDir/schemas/demoDebug"

  // Applies to 'demoDebug' and 'demoRelease'
  schemaDirectory "demo", "$projectDir/schemas/demo"

  // Applies to 'demoDebug' and 'fullDebug'
  schemaDirectory "debug", "$projectDir/schemas/debug"

  // Applies to variants that aren't matched by other configurations.
  schemaDirectory "$projectDir/schemas"
}

Kotlin

room {
  // Applies to 'demoDebug' only
  schemaDirectory("demoDebug", "$projectDir/schemas/demoDebug")

  // Applies to 'demoDebug' and 'demoRelease'
  schemaDirectory("demo", "$projectDir/schemas/demo")

  // Applies to 'demoDebug' and 'fullDebug'
  schemaDirectory("debug", "$projectDir/schemas/debug")

  // Applies to variants that aren't matched by other configurations.
  schemaDirectory("$projectDir/schemas")
}

הגדרת מיקום הסכימה באמצעות אפשרות של מעבד אנוטציות

אם אתם משתמשים בגרסה 2.5.2 או בגרסה מוקדמת יותר של Room, או אם אתם לא משתמשים ב-Room Gradle Plugin, צריך להגדיר את מיקום הסכימה באמצעות האפשרות room.schemaLocationannotation processor.

הקבצים בספרייה הזו משמשים כקלט ופלט למשימות מסוימות של Gradle. כדי שהבנייה המצטברת והבנייה במטמון יהיו מדויקות ויעילות, צריך להשתמש ב-CommandLineArgumentProvider של Gradle כדי לעדכן את Gradle לגבי הספרייה הזו.

קודם מעתיקים את המחלקה RoomSchemaArgProvider שמוצגת למטה לקובץ ה-Gradle build של המודול. השיטה asArguments() במחלקה לדוגמה מעבירה את room.schemaLocation=${schemaDir.path} אל KSP. אם אתם משתמשים ב-KAPT וב-javac, צריך לשנות את הערך הזה ל--Aroom.schemaLocation=${schemaDir.path}.

מגניב

class RoomSchemaArgProvider implements CommandLineArgumentProvider {

  @InputDirectory
  @PathSensitive(PathSensitivity.RELATIVE)
  File schemaDir

  RoomSchemaArgProvider(File schemaDir) {
    this.schemaDir = schemaDir
  }

  @Override
  Iterable<String> asArguments() {
    // Note: If you're using KAPT and javac, change the line below to
    // return ["-Aroom.schemaLocation=${schemaDir.path}".toString()].
    return ["room.schemaLocation=${schemaDir.path}".toString()]
  }
}

Kotlin

class RoomSchemaArgProvider(
  @get:InputDirectory
  @get:PathSensitive(PathSensitivity.RELATIVE)
  val schemaDir: File
) : CommandLineArgumentProvider {

  override fun asArguments(): Iterable<String> {
    // Note: If you're using KAPT and javac, change the line below to
    // return listOf("-Aroom.schemaLocation=${schemaDir.path}").
    return listOf("room.schemaLocation=${schemaDir.path}")
  }
}

לאחר מכן מגדירים את אפשרויות ההידור כך שישתמשו ב-RoomSchemaArgProvider עם ספריית הסכימה שצוינה:

מגניב

// For KSP, configure using KSP extension:
ksp {
  arg(new RoomSchemaArgProvider(new File(projectDir, "schemas")))
}

// For javac or KAPT, configure using android DSL:
android {
  ...
  defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        compilerArgumentProviders(
          new RoomSchemaArgProvider(new File(projectDir, "schemas"))
        )
      }
    }
  }
}

Kotlin

// For KSP, configure using KSP extension:
ksp {
  arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}

// For javac or KAPT, configure using android DSL:
android {
  ...
  defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        compilerArgumentProviders(
          RoomSchemaArgProvider(File(projectDir, "schemas"))
        )
      }
    }
  }
}

בדיקת העברה יחידה

לפני שבודקים את ההעברות, מוסיפים את androidx.room:room-testing Maven artifact מ-Room לתלות של הבדיקה ומוסיפים את המיקום של הסכימה המיוצאת כתיקיית נכסים:

build.gradle

מגניב

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets.
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

dependencies {
    ...
    androidTestImplementation "androidx.room:room-testing:2.7.2"
}

Kotlin

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets.
        getByName("androidTest").assets.srcDir("$projectDir/schemas")
    }
}

dependencies {
    ...
    testImplementation("androidx.room:room-testing:2.7.2")
}

חבילת הבדיקה מספקת מחלקה MigrationTestHelper שיכולה לקרוא קובצי סכימה שיוצאו. החבילה גם מיישמת את הממשק של JUnit4 TestRule, כך שהיא יכולה לנהל מסדי נתונים שנוצרו.

בדוגמה הבאה מוצג מבחן להעברה יחידה:

Kotlin

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            MigrationDb::class.java.canonicalName,
            FrameworkSQLiteOpenHelperFactory()
    )

    @Test
    @Throws(IOException::class)
    fun migrate1To2() {
        var db = helper.createDatabase(TEST_DB, 1).apply {
            // Database has schema version 1. Insert some data using SQL queries.
            // You can't use DAO classes because they expect the latest schema.
            execSQL(...)

            // Prepare for the next version.
            close()
        }

        // Re-open the database with version 2 and provide
        // MIGRATION_1_2 as the migration process.
        db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
    }
}

Java

@RunWith(AndroidJUnit4.class)
public class MigrationTest {
    private static final String TEST_DB = "migration-test";

    @Rule
    public MigrationTestHelper helper;

    public MigrationTest() {
        helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
                MigrationDb.class.getCanonicalName(),
                new FrameworkSQLiteOpenHelperFactory());
    }

    @Test
    public void migrate1To2() throws IOException {
        SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);

        // Database has schema version 1. Insert some data using SQL queries.
        // You can't use DAO classes because they expect the latest schema.
        db.execSQL(...);

        // Prepare for the next version.
        db.close();

        // Re-open the database with version 2 and provide
        // MIGRATION_1_2 as the migration process.
        db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2);

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
    }
}

בדיקת כל ההעברות

אפשר לבדוק העברה מצטברת אחת, אבל מומלץ לכלול בדיקה שכוללת את כל ההעברות שהוגדרו למסד הנתונים של האפליקציה. כך אפשר לוודא שאין הבדלים בין מופע מסד נתונים שנוצר לאחרונה לבין מופע ישן יותר שפעל לפי נתיבי ההעברה שהוגדרו.

בדוגמה הבאה מוצגת בדיקה של כל ההעברות שהוגדרו:

Kotlin

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

    // Array of all migrations.
    private val ALL_MIGRATIONS = arrayOf(
            MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)

    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            AppDatabase::class.java.canonicalName,
            FrameworkSQLiteOpenHelperFactory()
    )

    @Test
    @Throws(IOException::class)
    fun migrateAll() {
        // Create earliest version of the database.
        helper.createDatabase(TEST_DB, 1).apply {
            close()
        }

        // Open latest version of the database. Room validates the schema
        // once all migrations execute.
        Room.databaseBuilder(
            InstrumentationRegistry.getInstrumentation().targetContext,
            AppDatabase::class.java,
            TEST_DB
        ).addMigrations(*ALL_MIGRATIONS).build().apply {
            openHelper.writableDatabase.close()
        }
    }
}

Java

@RunWith(AndroidJUnit4.class)
public class MigrationTest {
    private static final String TEST_DB = "migration-test";

    @Rule
    public MigrationTestHelper helper;

    public MigrationTest() {
        helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
                AppDatabase.class.getCanonicalName(),
                new FrameworkSQLiteOpenHelperFactory());
    }

    @Test
    public void migrateAll() throws IOException {
        // Create earliest version of the database.
        SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);
        db.close();

        // Open latest version of the database. Room validates the schema
        // once all migrations execute.
        AppDatabase appDb = Room.databaseBuilder(
                InstrumentationRegistry.getInstrumentation().getTargetContext(),
                AppDatabase.class,
                TEST_DB)
                .addMigrations(ALL_MIGRATIONS).build();
        appDb.getOpenHelper().getWritableDatabase();
        appDb.close();
    }

    // Array of all migrations.
    private static final Migration[] ALL_MIGRATIONS = new Migration[]{
            MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4};
}

טיפול תקין בנתיבי העברה חסרים

אם Room לא יכול למצוא נתיב העברה לשדרוג מסד נתונים קיים במכשיר לגרסה הנוכחית, מתרחשת שגיאת IllegalStateException. אם מקובל לאבד נתונים קיימים כשנתיב ההעברה חסר, צריך לקרוא לשיטת ה-builder של fallbackToDestructiveMigration() כשיוצרים את מסד הנתונים:

Kotlin

Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
        .fallbackToDestructiveMigration()
        .build()

Java

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
        .fallbackToDestructiveMigration()
        .build();

בשיטה הזו, Room יוצר מחדש את הטבלאות במסד הנתונים של האפליקציה באופן הרסני, כשצריך לבצע העברה מצטברת ולא מוגדר נתיב העברה.

אם אתם רוצים ש-Room יחזור ליצירה הרסנית רק במצבים מסוימים, יש כמה חלופות ל-fallbackToDestructiveMigration():

  • אם גרסאות ספציפיות של היסטוריית הסכימה גורמות לשגיאות שאי אפשר לפתור באמצעות נתיבי העברה, צריך להשתמש ב-fallbackToDestructiveMigrationFrom() במקום זאת. השיטה הזו מציינת שרוצים ש-Room יחזור ליצירה הרסנית רק כשמבצעים העברה מגרסאות ספציפיות.
  • אם רוצים ש-Room יחזור ליצירה הרסנית רק כשמעבירים נתונים מגרסת מסד נתונים חדשה לגרסה ישנה יותר, צריך להשתמש ב-fallbackToDestructiveMigrationOnDowngrade().

טיפול בערכי ברירת מחדל של עמודות כשמשדרגים ל-Room 2.2.0

ב-Room מגרסה 2.2.0 ואילך, אפשר להגדיר ערך ברירת מחדל לעמודה באמצעות ההערה @ColumnInfo(defaultValue = "..."). בגרסאות שקודמות לגרסה 2.2.0, הדרך היחידה להגדיר ערך ברירת מחדל לעמוד היא להגדיר אותו ישירות בהצהרת SQL שמופעלת, וכך נוצר ערך ברירת מחדל ש-Room לא יודע עליו. המשמעות היא שאם מסד נתונים נוצר במקור על ידי גרסה של Room שקודמת לגרסה 2.2.0, יכול להיות שתצטרכו לספק נתיב מיגרציה מיוחד כדי לשדרג את האפליקציה לשימוש ב-Room 2.2.0, עבור ערכי ברירת מחדל קיימים שהגדרתם בלי להשתמש בממשקי Room API.

לדוגמה, נניח שבגרסה 1 של מסד נתונים מוגדרת ישות Song:

Kotlin

// Song entity, database version 1, Room 2.1.0.
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String
)

Java

// Song entity, database version 1, Room 2.1.0.
@Entity
public class Song {
    @PrimaryKey
    final long id;
    final String title;
}

נניח שגרסה 2 של אותו מסד נתונים מוסיפה עמודה חדשה NOT NULL ומגדירה נתיב העברה מגרסה 1 לגרסה 2:

Kotlin

// Song entity, database version 2, Room 2.1.0.
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String,
    val tag: String // Added in version 2.
)

// Migration from 1 to 2, Room 2.1.0.
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE Song ADD COLUMN tag TEXT NOT NULL DEFAULT ''")
    }
}

Java

// Song entity, database version 2, Room 2.1.0.
@Entity
public class Song {
    @PrimaryKey
    final long id;
    final String title;
    @NonNull
    final String tag; // Added in version 2.
}


// Migration from 1 to 2, Room 2.1.0.
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL(
            "ALTER TABLE Song ADD COLUMN tag TEXT NOT NULL DEFAULT ''");
    }
};

הדבר גורם להבדל בטבלה הבסיסית בין עדכונים לבין התקנות חדשות של האפליקציה. הסיבה לכך היא שערך ברירת המחדל של העמודה tag מוצהר רק בנתיב ההעברה מגרסה 1 לגרסה 2, ולכן למשתמשים שמתקינים את האפליקציה החל מגרסה 2 אין את ערך ברירת המחדל של tag בסכימת מסד הנתונים שלהם.

בגרסאות של Room שקודמות לגרסה 2.2.0, הפער הזה לא מזיק. עם זאת, אם בהמשך האפליקציה תשודרג לשימוש ב-Room 2.2.0 ואילך, והשינוי יכלול ערך ברירת מחדל ל-tag באמצעות ההערה @ColumnInfo,‏ Room יוכל לראות את אי ההתאמה הזו.Song התוצאה היא אימות סכימה שנכשל.

כדי לוודא שסכימת מסד הנתונים עקבית אצל כל המשתמשים כשמצהירים על ערכי ברירת מחדל של עמודות בנתיבי ההעברה הקודמים, צריך לבצע את הפעולות הבאות בפעם הראשונה שמשדרגים את האפליקציה לשימוש ב-Room 2.2.0 ואילך:

  1. מגדירים ערכי ברירת מחדל לעמודות בכיתות הישות המתאימות באמצעות ההערה @ColumnInfo.
  2. מגדילים את מספר הגרסה של מסד הנתונים ב-1.
  3. מגדירים נתיב העברה לגרסה החדשה שמיישמת את האסטרטגיה של מחיקה ויצירה מחדש כדי להוסיף את ערכי ברירת המחדל הנדרשים לעמודות הקיימות.

בדוגמה הבאה אפשר לראות את התהליך:

Kotlin

// Migration from 2 to 3, Room 2.2.0.
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("""
                CREATE TABLE new_Song (
                    id INTEGER PRIMARY KEY NOT NULL,
                    name TEXT,
                    tag TEXT NOT NULL DEFAULT ''
                )
                """.trimIndent())
        database.execSQL("""
                INSERT INTO new_Song (id, name, tag)
                SELECT id, name, tag FROM Song
                """.trimIndent())
        database.execSQL("DROP TABLE Song")
        database.execSQL("ALTER TABLE new_Song RENAME TO Song")
    }
}

Java

// Migration from 2 to 3, Room 2.2.0.
static final Migration MIGRATION_2_3 = new Migration(2, 3) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("CREATE TABLE new_Song (" +
                "id INTEGER PRIMARY KEY NOT NULL," +
                "name TEXT," +
                "tag TEXT NOT NULL DEFAULT '')");
        database.execSQL("INSERT INTO new_Song (id, name, tag) " +
                "SELECT id, name, tag FROM Song");
        database.execSQL("DROP TABLE Song");
        database.execSQL("ALTER TABLE new_Song RENAME TO Song");
    }
};