带有更新的预填充数据库的 Android Room 迁移

Posted

技术标签:

【中文标题】带有更新的预填充数据库的 Android Room 迁移【英文标题】:Android Room migration with updated pre-populated database 【发布时间】:2021-11-29 12:36:28 【问题描述】:

我对 Room 和使用预填充数据库的迁移有点头疼。

解释

我目前正在使用 Room 和预填充的数据库。使用第一个版本(版本 1),数据库加载正常,一切正常。

问题是,此时我需要向数据库中添加三个新表,其中包含数据。所以我开始更新我拥有的版本 1 数据库,并创建了所有包含我需要的数据的表和行。

我第一次尝试时,直接将新的.sqlite数据库推入assets文件夹并将版本号更改为2,但是Room当然给出了它需要知道如何处理迁移1_2的错误,所以我添加了迁移规则

    .addMigrations(new Migration(1,2) 
        @Override
        public void migrate(@NonNull SupportSQLiteDatabase database) 
            database.execSQL("CREATE TABLE ...);
            database.execSQL("CREATE TABLE ...);
            database.execSQL("CREATE TABLE ...);
        
    ...

想如果我告诉 Room 创建这些表,它会连接到 assets 中的新数据库并相应地填充表。 但这当然不起作用,通过查看数据库检查器,很明显这些表存在但它们是空的。

我不太喜欢的解决方案

经过一番修改,最后我发现有用的是拥有更新数据库的副本,在其中导航(我目前正在使用 DB Browser for SQLite),获取新的 SQL 查询填充行,并相应地格式化 database.execSQL 语句以将新数据插入表中:

    .addMigrations(new Migration(1,2) 
        @Override
        public void migrate(@NonNull SupportSQLiteDatabase database) 
            database.execSQL("CREATE TABLE ...);
            database.execSQL("CREATE TABLE ...);
            database.execSQL("CREATE TABLE ...);
    
            database.execSQL("INSERT INTO ...");
            database.execSQL("INSERT INTO ...");
            database.execSQL("INSERT INTO ...");
            database.execSQL("INSERT INTO ...");

        
    ...

对于我们正在处理包含小数据的行的情况,我发现这是一个“可接受的”解决方案,但在我的情况下,我正在处理具有很长字符串的行,这会带来一系列不便:

从数据库数据中提取的 SQL 语句需要正确格式化:' 符号需要处理," 可能出现在长字符串和换行符中; 需要保持数据库和行的插入语句之间的一致性;

问题

请注意,fallbackToDestructiveMigration() 不是一个可接受的选项,因为版本 1 中的数据库中包含用户创建的数据,并且需要在迁移之间保留。

那么,有没有一种解决方案可以让我直接将新的 .sqlite 数据库推送到资产中,而无需编写大量的 INSERT 和 CREATE TABLE 语句,并让 Room 自动处理其中的新数据,同时保留旧表数据?


感谢您的宝贵时间!

【问题讨论】:

【参考方案1】:

也许考虑

    将新数据库放入适合新安装应用程序的资产文件夹中,以便 createFromAsset 复制此版本 2 数据库以进行新安装。

    在迁移中,将资产复制到具有不同数据库名称的数据库文件夹中。

    在迁移中创建新表。

    仍在迁移中,对于每个新表,从不同名称的新数据库中提取所有数据,然后使用光标将数据插入现有数据库。

    仍在迁移中,关闭不同名称的数据库并删除文件。

以下是这些方面的迁移代码(没有架构更改,只是新的预填充数据),最近的回答是 Kotlin 而不是 Java:-

    val migration1_2 = object: Migration(1,2) 
        val assetFileName = "appdatabase.db" 
        val tempDBName = "temp_" + assetFileName
        val bufferSize = 1024 * 4
        @SuppressLint("Range")
        override fun migrate(database: SupportSQLiteDatabase) 
            val asset = contextPassed?.assets?.open(assetFileName) /* Get the asset as an InputStream */
            val tempDBPath = contextPassed?.getDatabasePath(tempDBName) /* Deduce the file name to copy the database to */
            val os = tempDBPath?.outputStream() /* and get an OutputStream for the new version database */

            /* Copy the asset to the respective file (OutputStream) */
            val buffer = ByteArray(bufferSize)
            while (asset!!.read(buffer,0,bufferSize) > 0) 
                os!!.write(buffer)
            
            /* Flush and close the newly created database file */
            os!!.flush()
            os.close()
            /* Close the asset inputStream */
            asset.close()
            /* Open the new database */
            val version2db = SQLiteDatabase.openDatabase(tempDBPath.path,null,SQLiteDatabase.OPEN_READONLY)
            /* Grab all of the supplied rows */
            val v2csr = version2db.rawQuery("SELECT * FROM user WHERE userId < $User.USER_DEMARCATION",null)
            /* Insert into the actual database ignoring duplicates (by userId) */
            while (v2csr.moveToNext()) 
                database.execSQL("INSERT OR IGNORE INTO user VALUES($v2csr.getLong(v2csr.getColumnIndex("userId")),'$v2csr.getString(v2csr.getColumnIndex("userName"))')",)
            
            /* close cursor and the newly created database */
            v2csr.close()
            version2db.close()
            tempDBPath.delete() /* Delete the temporary database file */
        
    

测试上述代码时请注意。我最初尝试附加新的(临时)数据库。这有效并复制了数据,但 ATTACH 或 DETACH(或两者)过早地结束了迁移运行的事务,导致 Room 无法打开数据库并导致异常。

如果不是这样,那么在附加新数据库的情况下,可以使用简单的INSERT INTO main.the_table SELECT * FROM the_attached_schema_name.the_table; 而不是使用游标作为中间人。

无需编写大量的 INSERT 和 CREATE TABLE 语句

INSERT 上面处理过。

可以以类似的方式从新资产数据库中提取 CREATE SQL,方法是:-

`SELECT name,sql FROM sqlite_master WHERE type = 'table' AND name in (a_csv_of_the_table_names (enclosed in single quotes))`

例如SELECT name,sql FROM sqlite_master WHERE type = 'table' AND name IN ('viewLog','message');;

结果(用于演示的任意数据库):-

name 是表的名称,sql 然后是用于创建表的 sql。 或者,创建表的 SQL 可以在生成的 java(从 android 视图中可见)中编译后在与使用 @Database 注释但后缀为 _Impl 的类同名的类中找到。将有一个名为 createAlltables 的方法,它具有创建所有表(和其他项目)的 SQL,例如(再次只是一个任意示例):-

请注意,红色的划线用于 room_master 表,ROOM 会创建它,并且资产中不需要它(这是房间用来检查架构是否已更改的内容)

工作示例

版本 1(准备迁移到版本 2)

以下是一个工作示例。在版本 1 下,使用了一个名为 original(实体 OriginalEnity)的表,其中包含通过预填充数据库的数据(5 行),然后添加一行以反映用户提供/输入的数据。当应用程序运行时,表的内容被提取并写入日志:-

D/DBINFOoriginal: Name is name1 ID is 1 - DB Version is 1
D/DBINFOoriginal: Name is name2 ID is 2 - DB Version is 1
D/DBINFOoriginal: Name is name3 ID is 3 - DB Version is 1
D/DBINFOoriginal: Name is name4 ID is 4 - DB Version is 1
D/DBINFOoriginal: Name is name5 ID is 5 - DB Version is 1
D/DBINFOoriginal: Name is App User Data ID is 6 - DB Version is 1

数据库检查器显示:-

第 2 版

添加的 3 个新实体/表(newEntity1、2 和 3 个表名分别为 new1、new2 和 new3)相同的基本结构。

在创建实体并编译 SQL 后,按照 java 中的 createAlltables 方法从 TheDatabase_Impl 类中提取(包括 3 个附加索引):-

然后在 SQLite 工具中使用此 SQL 来创建新表并用一些数据填充它们:-

/* FOR VERSION 2 */
/* Create statments copied from TheDatabase_Impl */
DROP TABLE IF EXISTS new1;
DROP TABLE IF EXISTS new2;
DROP TABLE IF EXISTS new3;
CREATE TABLE IF NOT EXISTS `new1` (`new1_id` INTEGER, `new1_name` TEXT, PRIMARY KEY(`new1_id`));
CREATE INDEX IF NOT EXISTS `index_new1_new1_name` ON `new1` (`new1_name`);
CREATE TABLE IF NOT EXISTS `new2` (`new2_id` INTEGER, `new2_name` TEXT, PRIMARY KEY(`new2_id`));
CREATE INDEX IF NOT EXISTS `index_new2_new2_name` ON `new2` (`new2_name`);
CREATE TABLE IF NOT EXISTS `new3` (`new3_id` INTEGER, `new3_name` TEXT, PRIMARY KEY(`new3_id`));
CREATE INDEX IF NOT EXISTS `index_new3_new3_name` ON `new3` (`new3_name`);

INSERT OR IGNORE INTO new1 (new1_name) VALUES ('new1_name1'),('new1_name2');
INSERT OR IGNORE INTO new2 (new2_name) VALUES ('new2_name1'),('new2_name2');
INSERT OR IGNORE INTO new3 (new3_name) VALUES ('new3_name1'),('new3_name2');

数据库保存并复制到assets文件夹中(原改名):-

然后是迁移代码(完整的数据库助手),它:-

仅由表名的 String[] 驱动 复制资产(新数据库)并通过 SQLite API 打开它 根据资产创建表、索引和触发器(必须匹配房间生成的模式(因此从之前生成的java复制sql)) 它通过从 sqlite_master 表中提取相应的 SQL 来做到这一点 通过将资产数据库中的数据提取到光标中然后插入到 Room 数据库中来填充新创建的 Room 表(这不是最有效的方式,但 Room 在事务中运行迁移)

是:-

@Database(entities = 
        OriginalEntity.class, /* on it's own for V1 */
        /* ADDED NEW TABLES FOR V2 */NewEntity1.class,NewEntity2.class,NewEntity3.class
        ,
        version = TheDatabase.DATABASE_VERSION,
        exportSchema = false
)
abstract class TheDatabase extends RoomDatabase 
    public static final String DATABASE_NAME = "thedatabase.db";
    public static final int DATABASE_VERSION = 2; //<<<<<<<<<< changed */
    abstract AllDao getAllDao();

    private static volatile TheDatabase instance = null;
    private static Context currentContext;

    public static TheDatabase getInstance(Context context) 
        currentContext = context;
        if (instance == null) 
            instance = Room.databaseBuilder(context, TheDatabase.class, DATABASE_NAME)
                    .allowMainThreadQueries() /* for convenience run on main thread */
                    .createFromAsset(DATABASE_NAME)
                    .addMigrations(migration1_2)
                    .build();
        
        return instance;
    

    static Migration migration1_2 = new Migration(1, 2) 
        @Override
        public void migrate(@NonNull SupportSQLiteDatabase database) 
            /* Copy the asset into the database folder (with different name) */
            File assetDBFile = getNewAssetDatabase(currentContext,DATABASE_NAME);
            /* Open the assetdatabase */
            SQLiteDatabase assetDB = SQLiteDatabase.openDatabase(assetDBFile.getPath(),null,SQLiteDatabase.OPEN_READWRITE);
            /* Build (create and populate) the new ROOM tables and indexes from the asset database  */
            buildNewTables(
                    new String[]
                            NewEntity1.TABLE_NAME,
                            NewEntity2.TABLE_NAME,
                            NewEntity3.TABLE_NAME,
                    database /* ROOM DATABASE */,
                    assetDB /* The copied and opened asset database as an SQliteDatabase */
            );
            /* done with the asset database */
            assetDB.close();
            assetDBFile.delete();
        
    ;

    private static void buildNewTables(String[] tablesToBuild, SupportSQLiteDatabase actualDB, SQLiteDatabase assetDB) 
        StringBuilder args = new StringBuilder();
        boolean afterFirst = false;
        for (String tableName: tablesToBuild) 
            if (afterFirst) 
                args.append(",");
            
            afterFirst = true;
            args.append("'").append(tableName).append("'");
        
        /* Get SQL for anything related to the table (table, index, trigger) to the tables and build it */
        /* !!!!WARNING!!!! NOT TESTED VIEWS */
        /* !!!!WARNING!!!! may not cope with Foreign keys as conflicts could occur */
        Cursor csr = assetDB.query(
                "sqlite_master",
                new String[]"name","sql", "CASE WHEN type = 'table' THEN 1 WHEN type = 'index' THEN 3 ELSE 2 END AS sort",
                "tbl_name IN (" + args.toString() + ")",
                null,
                null,null, "sort"
        );
        while (csr.moveToNext()) 
            Log.d("CREATEINFO","executing SQL:- " + csr.getString(csr.getColumnIndex("sql")));
            actualDB.execSQL(csr.getString(csr.getColumnIndex("sql")));
        
        /* Populate the tables */
        /* !!!!WARNING!!!! may not cope with Foreign keys as conflicts could occur */
        /*      no set order for the tables so a child table may not be loaded before it's parent(s) */
        ContentValues cv = new ContentValues();
        for (String tableName: tablesToBuild) 
            csr = assetDB.query(tableName,null,null,null,null,null,null);
            while (csr.moveToNext()) 
                cv.clear();
                for (String columnName: csr.getColumnNames()) 
                    cv.put(columnName,csr.getString(csr.getColumnIndex(columnName)));
                    actualDB.insert(tableName, OnConflictStrategy.IGNORE,cv);
                
            
        
        csr.close();
    

    private static File getNewAssetDatabase(Context context, String assetDatabaseFileName) 
        String tempDBPrefix = "temp_";
        int bufferSize = 1024 * 8;
        byte[] buffer = new byte[bufferSize];
        File assetDatabase = context.getDatabasePath(tempDBPrefix+DATABASE_NAME);
        InputStream assetIn;
        OutputStream assetOut;
        /* Delete the AssetDatabase (temp DB) if it exists */
        if (assetDatabase.exists()) 
            assetDatabase.delete(); /* should not exist but just in case */
        
        /* Just in case the databases folder (data/data/packagename/databases)
            doesn't exist create it
            This should never be the case as Room DB uses it
         */
        if (!assetDatabase.getParentFile().exists()) 
            assetDatabase.mkdirs();
        
        try 
            assetIn = context.getAssets().open(assetDatabaseFileName);
            assetOut = new FileOutputStream(assetDatabase);
            while(assetIn.read(buffer) > 0) 
                assetOut.write(buffer);
            
            assetOut.flush();
            assetOut.close();
            assetIn.close();
         catch (IOException e) 
            e.printStackTrace();
            throw new RuntimeException("Error retrieving Asset Database from asset " + assetDatabaseFileName);
        
        return assetDatabase;
    

Activity中的代码是:-

public class MainActivity extends AppCompatActivity 

    TheDatabase db;
    AllDao dao;
    private static final String TAG = "DBINFO";

    @Override
    protected void onCreate(Bundle savedInstanceState) 
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        /* Original */
        db = TheDatabase.getInstance(this);
        dao = db.getAllDao();
        OriginalEntity newOE = new OriginalEntity();
        newOE.name = "App User Data";
        dao.insert(newOE);
        for(OriginalEntity o: dao.getAll()) 
            Log.d(TAG+OriginalEntity.TABLE_NAME,"Name is " + o.name + " ID is " + o.id + " - DB Version is " + TheDatabase.DATABASE_VERSION);
        

        /* Added for V2 */
        for (NewEntity1 n: dao.getAllNewEntity1s()) 
            Log.d(TAG+NewEntity1.TABLE_NAME,"Names is " + n.name + " ID is " + n.id + " - DB Version is " + TheDatabase.DATABASE_VERSION);
        
        for (NewEntity2 n: dao.getAllNewEntity2s()) 
            Log.d(TAG+NewEntity2.TABLE_NAME,"Names is " + n.name + " ID is " + n.id + " - DB Version is " + TheDatabase.DATABASE_VERSION);
        
        for (NewEntity3 n: dao.getAllNewEntity3s()) 
            Log.d(TAG+NewEntity3.TABLE_NAME,"Names is " + n.name + " ID is " + n.id + " - DB Version is " + TheDatabase.DATABASE_VERSION);
        
    

请参阅版本 1 部分和代码中的 cmets 以用于 V1 运行。 V2 运行(初始)日志的结果输出是

:-

2021-10-11 13:02:50.939 D/CREATEINFO: executing SQL:- CREATE TABLE `new1` (`new1_id` INTEGER, `new1_name` TEXT, PRIMARY KEY(`new1_id`))
2021-10-11 13:02:50.941 D/CREATEINFO: executing SQL:- CREATE TABLE `new2` (`new2_id` INTEGER, `new2_name` TEXT, PRIMARY KEY(`new2_id`))
2021-10-11 13:02:50.942 D/CREATEINFO: executing SQL:- CREATE TABLE `new3` (`new3_id` INTEGER, `new3_name` TEXT, PRIMARY KEY(`new3_id`))
2021-10-11 13:02:50.942 D/CREATEINFO: executing SQL:- CREATE INDEX `index_new1_new1_name` ON `new1` (`new1_name`)
2021-10-11 13:02:50.943 D/CREATEINFO: executing SQL:- CREATE INDEX `index_new2_new2_name` ON `new2` (`new2_name`)
2021-10-11 13:02:50.944 D/CREATEINFO: executing SQL:- CREATE INDEX `index_new3_new3_name` ON `new3` (`new3_name`)
2021-10-11 13:02:51.006 D/DBINFOoriginal: Name is name1 ID is 1 - DB Version is 2
2021-10-11 13:02:51.006 D/DBINFOoriginal: Name is name2 ID is 2 - DB Version is 2
2021-10-11 13:02:51.006 D/DBINFOoriginal: Name is name3 ID is 3 - DB Version is 2
2021-10-11 13:02:51.006 D/DBINFOoriginal: Name is name4 ID is 4 - DB Version is 2
2021-10-11 13:02:51.006 D/DBINFOoriginal: Name is name5 ID is 5 - DB Version is 2
2021-10-11 13:02:51.006 D/DBINFOoriginal: Name is App User Data ID is 6 - DB Version is 2
2021-10-11 13:02:51.006 D/DBINFOoriginal: Name is App User Data ID is 7 - DB Version is 2
2021-10-11 13:02:51.010 D/DBINFOnew1: Names is new1_name1 ID is 1 - DB Version is 2
2021-10-11 13:02:51.010 D/DBINFOnew1: Names is new1_name2 ID is 2 - DB Version is 2
2021-10-11 13:02:51.012 D/DBINFOnew2: Names is new2_name1 ID is 1 - DB Version is 2
2021-10-11 13:02:51.012 D/DBINFOnew2: Names is new2_name2 ID is 2 - DB Version is 2
2021-10-11 13:02:51.013 D/DBINFOnew3: Names is new3_name1 ID is 1 - DB Version is 2
2021-10-11 13:02:51.013 D/DBINFOnew3: Names is new3_name2 ID is 2 - DB Version is 2
请注意,用户数据已被保留(第一个 App 用户数据 ....,第二个在 Activity 运行时添加)。

道(AllDao)是:-

@Dao
abstract class AllDao 
    /* Original Version 1 Dao's */
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    abstract long insert(OriginalEntity originalEntity);
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    abstract long[] insert(OriginalEntity ... originalEntities);
    @Query("SELECT * FROM original")
    abstract List<OriginalEntity> getAll();

    /* New Version 2 Dao's */
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    abstract long insert(NewEntity1 newEntity1);
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    abstract long insert(NewEntity2 newEntity2);
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    abstract long insert(NewEntity3 newEntity3);
    @Query("SELECT * FROM " + NewEntity1.TABLE_NAME)
    abstract List<NewEntity1> getAllNewEntity1s();
    @Query("SELECT * FROM " + NewEntity2.TABLE_NAME)
    abstract List<NewEntity2> getAllNewEntity2s();
    @Query("SELECT * FROM " + NewEntity3.TABLE_NAME)
    abstract List<NewEntity3> getAllNewEntity3s();

实体是:-

@Entity(tableName = OriginalEntity.TABLE_NAME)
class OriginalEntity 
    public static final String TABLE_NAME = "original";
    public static final String COL_ID = TABLE_NAME +"_id";
    public static final String COL_NAME = TABLE_NAME + "_name";

    @PrimaryKey
    @ColumnInfo(name = COL_ID)
    Long id = null;
    @ColumnInfo(name = COL_NAME, index = true)
    String name;

对于 V2:-

@Entity(tableName = NewEntity1.TABLE_NAME)
class NewEntity1 

    public static final String TABLE_NAME = "new1";
    public static final String COl_ID = TABLE_NAME + "_id";
    public static final String COL_NAME = TABLE_NAME + "_name";

    @PrimaryKey
    @ColumnInfo(name = COl_ID)
    Long id = null;
    @ColumnInfo(name = COL_NAME, index = true)
    String name;

和:-

@Entity(tableName = NewEntity2.TABLE_NAME)
class NewEntity2 

    public static final String TABLE_NAME = "new2";
    public static final String COl_ID = TABLE_NAME + "_id";
    public static final String COL_NAME = TABLE_NAME + "_name";

    @PrimaryKey
    @ColumnInfo(name = COl_ID)
    Long id = null;
    @ColumnInfo(name = COL_NAME, index = true)
    String name;

和:-

@Entity(tableName = NewEntity3.TABLE_NAME)
class NewEntity3 

    public static final String TABLE_NAME = "new3";
    public static final String COl_ID = TABLE_NAME + "_id";
    public static final String COL_NAME = TABLE_NAME + "_name";

    @PrimaryKey
    @ColumnInfo(name = COl_ID)
    Long id = null;
    @ColumnInfo(name = COL_NAME, index = true)
    String name;

最后测试新的应用安装(即没有迁移但从资产创建)

当运行输出到日志是(没有用户提供/输入数据):-

2021-10-11 13:42:48.272 D/DBINFOoriginal: Name is name1 ID is 1 - DB Version is 2
2021-10-11 13:42:48.272 D/DBINFOoriginal: Name is name2 ID is 2 - DB Version is 2
2021-10-11 13:42:48.272 D/DBINFOoriginal: Name is name3 ID is 3 - DB Version is 2
2021-10-11 13:42:48.272 D/DBINFOoriginal: Name is name4 ID is 4 - DB Version is 2
2021-10-11 13:42:48.272 D/DBINFOoriginal: Name is name5 ID is 5 - DB Version is 2
2021-10-11 13:42:48.272 D/DBINFOoriginal: Name is App User Data ID is 6 - DB Version is 2
2021-10-11 13:42:48.275 D/DBINFOnew1: Names is new1_name1 ID is 1 - DB Version is 2
2021-10-11 13:42:48.275 D/DBINFOnew1: Names is new1_name2 ID is 2 - DB Version is 2
2021-10-11 13:42:48.276 D/DBINFOnew2: Names is new2_name1 ID is 1 - DB Version is 2
2021-10-11 13:42:48.276 D/DBINFOnew2: Names is new2_name2 ID is 2 - DB Version is 2
2021-10-11 13:42:48.277 D/DBINFOnew3: Names is new3_name1 ID is 1 - DB Version is 2
2021-10-11 13:42:48.277 D/DBINFOnew3: Names is new3_name2 ID is 2 - DB Version is 2

注意

Room 将项目(表、列)的名称括在重音符号中,这使得无效的列名有效,例如 1 未封闭是无效的 1 封闭是有效的。尽管我怀疑不会,使用其他无效名称可能会导致问题(我尚未测试这方面)。 SQLite 本身在存储名称时会去除重音,例如:-

CREATE TABLE IF NOT EXISTS `testit` (`1`);
SELECT * FROM sqlite_master WHERE name = 'testit';
SELECT * FROM testit;

结果:-

即存储 SQL 时会保留重音符号,因此生成的 CREATE 是安全的。

和:-

即重音符号已被删除,并且该列仅命名为 1,这可能会在遍历光标中的列时导致问题(但可能不会)。

【讨论】:

以上是关于带有更新的预填充数据库的 Android Room 迁移的主要内容,如果未能解决你的问题,请参考以下文章

如何在首次运行时填充 Android Room 数据库表?

带有预填充数据库的房间

Android Room:按不工作排序

Room DAO 更新如何最好地处理旧的不需要的数据

使用带有 Android 的 Room 使用 UUID 作为主键

Android Room - 带有附加字段的多对多关系