Android Room持久化库:Upsert

138

Android的Room持久性库慷慨地包含了适用于对象或集合的@Insert和@Update注释。然而,我有一个用例(包含模型的推送通知),需要一个UPSERT,因为数据可能存在于数据库中,也可能不存在。

Sqlite没有本地支持upsert的功能,解决方法在这个SO问题中有描述。鉴于那里的解决方案,如何将它们应用到Room中?

更具体地说,如何在Room中实现插入或更新操作,而不会破坏任何外键约束?使用onConflict=REPLACE的insert将调用该行任何外键的onDelete。在我的情况下,onDelete会引起级联,并重新插入一行将导致删除其他表中具有外键的行,这不是预期的行为。

14个回答

118

编辑:

正如 @Tunji_D 所提到的, 从版本2.5.0-alpha03开始,Room官方支持@Upsert。(发行说明

请查看他的答案以了解更多详情。

旧版答案:

也许您可以将BaseDao设计为以下样式。

使用@Transaction保护upsert操作,并在插入失败时尝试仅更新。

@Dao
public abstract class BaseDao<T> {
    /**
    * Insert an object in the database.
    *
     * @param obj the object to be inserted.
     * @return The SQLite row id
     */
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    public abstract long insert(T obj);

    /**
     * Insert an array of objects in the database.
     *
     * @param obj the objects to be inserted.
     * @return The SQLite row ids   
     */
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    public abstract List<Long> insert(List<T> obj);

    /**
     * Update an object from the database.
     *
     * @param obj the object to be updated
     */
    @Update
    public abstract void update(T obj);

    /**
     * Update an array of objects from the database.
     *
     * @param obj the object to be updated
     */
    @Update
    public abstract void update(List<T> obj);

    /**
     * Delete an object from the database
     *
     * @param obj the object to be deleted
     */
    @Delete
    public abstract void delete(T obj);

    @Transaction
    public void upsert(T obj) {
        long id = insert(obj);
        if (id == -1) {
            update(obj);
        }
    }

    @Transaction
    public void upsert(List<T> objList) {
        List<Long> insertResult = insert(objList);
        List<T> updateList = new ArrayList<>();

        for (int i = 0; i < insertResult.size(); i++) {
            if (insertResult.get(i) == -1) {
                updateList.add(objList.get(i));
            }
        }

        if (!updateList.isEmpty()) {
            update(updateList);
        }
    }
}

这对性能来说是不好的,因为每个列表元素都会有多次数据库交互。 - Tunji_D
对于for循环中的每个插入,Room将创建和执行多个SQL语句和事务,每个都有其相应的Java对象分配和垃圾收集开销。本文详细介绍了这一点:https://hackernoon.com/squeezing-performance-from-sqlite-insertions-with-room-d769512f8330 - Tunji_D
15
但是,在for循环中没有“插入”。 - yeonseok.seo
3
这很有价值。这让我看到了Florina的帖子,你应该去读一下:https://medium.com/androiddevelopers/7-pro-tips-for-room-fbadea4bfbd1 — 感谢@yeonseok.seo的提示! - Benoit Duffez
问题在于这不是SQLite的UPSERT等价物。例如,如果您在表中有一个unique键,并且您正在插入一些行,其中包括0个主键和现有唯一键以及OnConflictStrategy.IGNORE策略,则该行将不会被插入,因为唯一键重复,这是预期的。问题是0主键是insert的有效值,但是对于update而言则是错误的值,该行将不会被更新。在原始的SQLite中,您可以使用ON CONFLICT(word) DO UPDATE来处理这种情况。 - James Bond

95

为了更加优雅地实现这一点,我建议有两个选项:

使用IGNORE作为OnConflictStrategy,检查insert操作的返回值(如果等于-1,则表示未插入行):

@Insert(onConflict = OnConflictStrategy.IGNORE)
long insert(Entity entity);

@Update(onConflict = OnConflictStrategy.IGNORE)
void update(Entity entity);

@Transaction
public void upsert(Entity entity) {
    long id = insert(entity);
    if (id == -1) {
        update(entity);   
    }
}

使用 FAIL 作为 OnConflictStrategy,处理 insert 操作中的异常:

@Insert(onConflict = OnConflictStrategy.FAIL)
void insert(Entity entity);

@Update(onConflict = OnConflictStrategy.FAIL)
void update(Entity entity);

@Transaction
public void upsert(Entity entity) {
    try {
        insert(entity);
    } catch (SQLiteConstraintException exception) {
        update(entity);
    }
}

12
这对于单个实体很有效,但在集合方面实现起来比较困难。筛选要插入的集合并将其从更新中筛选出来是很好的选择。 - Tunji_D
4
不知何故,当我使用第一种方法插入一个已存在的ID时,返回的行号比实际存在的还要大,而不是-1L。 - ElliotM
1
正如Ohmnibus在另一个答案中所说,最好使用@Transaction注释标记upsert方法- https://dev59.com/J1cO5IYBdhLWcg3whR_M#I7WiEYcBWogLw_1b4yog - Dr.jacky
2
你能解释一下为什么@Update注释具有FAIL或IGNORE的冲突策略吗?在什么情况下,Room会考虑更新查询存在冲突?如果我天真地解释Update注释上的冲突策略,我会说当有需要更新的内容时就会发生冲突,因此它永远不会更新。但这不是我看到的行为。更新查询是否可能存在冲突?或者如果更新导致另一个唯一键约束失败,会出现冲突吗? - Hylke
OnConflictStrategy.FAIL在2.3.0版本中已经被弃用。根据文档,“OnConflictStrategy.FAIL不像预期的那样工作。事务将被回滚。请改用OnConflictStrategy.ABORT”。请参阅https://developer.android.com/reference/androidx/room/OnConflictStrategy。 - zafar142003

52

编辑:

从版本2.5.0-alpha03开始,Room现在支持一个@Upsert注释。其使用示例可在Now in Android示例应用程序的pull request中看到。

旧回答:

我找不到一条SQLite查询语句能够在没有引起我的外键不必要更改的情况下进行插入或更新,因此我选择先进行插入,如果发生冲突则忽略,然后立即进行更新,再次忽略冲突。

插入和更新方法是受保护的,因此外部类只能看到并使用upsert方法。请记住,这并不是真正的upsert,因为如果任何MyEntity POJOS有空字段,它们将覆盖数据库中可能当前存在的内容。这对我来说不是警告,但对您的应用程序可能会有所影响。

@Insert(onConflict = OnConflictStrategy.IGNORE)
protected abstract void insert(List<MyEntity> entities);

@Update(onConflict = OnConflictStrategy.IGNORE)
protected abstract void update(List<MyEntity> entities);

@Transaction
public void upsert(List<MyEntity> entities) {
    insert(models);
    update(models);
}

6
你可能希望让它更高效,并检查返回值。-1表示任何冲突。 - jcuypers
23
最好使用@Transaction注解标记upsert方法。 - Ohmnibus
3
我会尽力进行翻译并使句子更加通俗易懂,但不改变原意。需要翻译的内容如下:我猜正确的方法是询问该值是否已经存在于数据库中(使用其主键)。您可以使用抽象类(替换DAO接口)或调用对象的DAO的类来实现此操作。 - Sebastian Corradi
@Ohmnibus 不需要,因为文档中指出:> 在插入、更新或删除方法上放置此注释没有任何影响,因为它们总是在事务内运行。同样,如果使用 Query 注释但运行更新或删除语句,则会自动将其包装在事务中。 请参阅事务文档 - Levon Vardanyan
2
@LevonVardanyan,您链接页面中的示例显示了一种非常类似于upsert的方法,包含插入和删除。此外,我们不是将注释放置在插入或更新上,而是放置在同时包含两者的方法上。 - Ohmnibus
显示剩余2条评论

11

34
请不要使用这种方法。如果您的数据中有任何外键引用,它会触发onDelete监听器,您可能不希望出现这种情况。 - Alexandr Zhurkov
@AlexandrZhurkov,我猜它应该只在更新时触发,那么如果实现了任何监听器,它都会正确执行。 无论如何,如果我们在数据上有监听器并且onDelete触发,则必须由代码处理。 - Vikas Pandey

6

这是 Kotlin 代码:

@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(entity: Entity): Long

@Update(onConflict = OnConflictStrategy.REPLACE)
fun update(entity: Entity)

@Transaction
fun upsert(entity: Entity) {
  val id = insert(entity)
   if (id == -1L) {
     update(entity)
  }
}

1
对于 Kotlin,long id = insert(entity) 应该改为 val id = insert(entity)。 - Kibotu
@Sam,当我不想用null更新数据而是保留旧值时,如何处理“null值”? - binrebin

3

仅供参考,以下是如何使用Kotlin保留模型数据(例如在计数器中使用)的更新:

//Your Dao must be an abstract class instead of an interface (optional database constructor variable)
@Dao
abstract class ModelDao(val database: AppDatabase) {

@Insert(onConflict = OnConflictStrategy.FAIL)
abstract fun insertModel(model: Model)

//Do a custom update retaining previous data of the model 
//(I use constants for tables and column names)
 @Query("UPDATE $MODEL_TABLE SET $COUNT=$COUNT+1 WHERE $ID = :modelId")
 abstract fun updateModel(modelId: Long)

//Declare your upsert function open
open fun upsert(model: Model) {
    try {
       insertModel(model)
    }catch (exception: SQLiteConstraintException) {
        updateModel(model.id)
    }
}
}

您还可以使用@Transaction和数据库构造变量来进行更复杂的事务处理,使用database.openHelper.writableDatabase.execSQL("SQL STATEMENT")。

3

我在这里找到了一篇关于它的有趣文章here

它与此处发布的https://dev59.com/J1cO5IYBdhLWcg3whR_M#50736568相同。但是,如果您想要一个惯用和干净的Kotlin版本,这里有:

    @Transaction
    open fun insertOrUpdate(objList: List<T>) = insert(objList)
        .withIndex()
        .filter { it.value == -1L }
        .forEach { update(objList[it.index]) }

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    abstract fun insert(obj: List<T>): List<Long>

    @Update
    abstract fun update(obj: T)

但是如果您执行多个“update”请求... 如果您有100个项目的列表,会怎样呢? - James Bond

2

或者,我们可以使用Sqlite v.3.24.0提供的UPSERT功能在Android Room中进行UPSERT,而不像@yeonseok.seo帖子中建议的那样在循环中手动执行UPSERT。

现在,Android 11和12默认使用Sqlite版本3.28.0和3.32.2支持此功能。如果您需要在Android 11之前的版本中使用此功能,则可以将默认Sqlite替换为自定义Sqlite项目(如https://github.com/requery/sqlite-android)或构建您自己的项目以获得最新Sqlite版本中可用但Android Sqlite默认未提供的其他功能。

如果设备上有从3.24.0开始的Sqlite版本,则可以在Android Room中使用UPSERT,如下所示:

@Query("INSERT INTO Person (name, phone) VALUES (:name, :phone) ON CONFLICT (name) DO UPDATE SET phone=excluded.phone")
fun upsert(name: String, phone: String)

唯一一个真正的upsert答案...我有一种感觉,其他帖子的作者只是不理解upsert的主要特点是在不知道其ID的情况下更新行。使用upsert,数据库可以仅使用唯一约束自动更新行,而无需主键或其他请求。 - James Bond
是的,这是Sqlite中真正的UPSERT。但是你可以看到它只支持Android 11和12,在之前的版本中不支持。现在,即使在支持此功能的设备上,Android Room仍不支持Android 11和12中UPSERT功能的注释。因此,在Android 11和12上调用真正的UPSERT功能,我们只有@Query("")选项。此外,大多数答案都是在Android 11和12之前发布的,因此设备上的Sqlite版本不支持UPSERT,这就是为什么人们不得不使用一些解决方法的原因。 - devger

0

0
  • 文章链接
  • 单行回答:当我们使用“replace”冲突策略插入时,旧行实际上被删除,而新行被添加。当我们进行upsert操作时,实际上是在更新现有的行。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接