用 Room 实现搜索

Implementing Search with Room

最近一直在胡思乱想Android Architecture Components (more specifically Room),但遇到了一些障碍。

我已经成功地建立了一个存储部门及其人员列表的房间数据库。以前,此数据是从服务器中提取的,但未存储在本地。搜索功能也是远程处理的,所以现在我也希望在本地处理搜索功能,但我对 SQL 的了解有点欠缺。

查看服务器上的 SQL 代码,搜索语句使用一堆 REGEXP 函数根据提供的查询搜索两个数据库。这似乎不是处理搜索的最佳方式,但它运行良好并且响应迅速。所以我试图在本地模仿这个,但很快发现 Android 不支持 REGEXP(不使用 NDK)。

至于 LIKEGLOB 运算符,它们的功能似乎非常有限。例如,我没有看到可以同时匹配多个关键字的方法;而使用 REGEXP 我可以用 or (|) 运算符替换空格来实现此功能。

因此,在寻找替代方案时我遇到了 full-text search (FTS); which is the method demonstrated in the Android documentation on implementing search。尽管 FTS 似乎是用于搜索完整文档,而不是像我的用例那样的简单数据。

无论如何,FTS isn't supported by Room.

因此,很自然地,我试图通过创建 SupportSQLiteOpenHelper.Factory 的实现来强制 Room 创建 FTS 虚拟 table 而不是标准 table。这个实现几乎是默认 FrameworkSQLiteOpenHelperFactory 和相关框架 类 的直接复制。必要的代码位在 SupportSQLiteDatabase 中,我在其中覆盖 execSQL 以在必要时注入虚拟 table 代码。

class FTSSQLiteDatabase(
    private val delegate: SQLiteDatabase,
    private val ftsOverrides: Array<out String>
) : SupportSQLiteDatabase {

    // Omitted code...

    override fun execSQL(sql: String) {
        delegate.execSQL(injectVirtualTable(sql))
    }

    override fun execSQL(sql: String, bindArgs: Array<out Any>) {
        delegate.execSQL(injectVirtualTable(sql), bindArgs)
    }

    private fun injectVirtualTable(sql: String): String {
        if (!shouldOverride(sql)) return sql

        var newSql = sql

        val tableIndex = sql.indexOf("TABLE")
        if (tableIndex != -1) {
            sql = sql.substring(0..(tableIndex - 1)) + "VIRTUAL " + sql.substring(tableIndex)

            val argumentIndex = sql.indexOf('(')
            if (argumentIndex != -1) {
                sql = sql.substring(0..(argumentIndex - 1) + "USING fts4" + sql.substring(argumentIndex)
            }
        }

        return newSql
    }

    private fun shouldOverride(sql: String): Boolean {
        if (!sql.startsWith("CREATE TABLE")) return false

        val split = sql.split('`')
        if (split.size >= 2) {
            val tableName = split[1]
            return ftsOverrides.contains(tableName)
        } else {
            return false
        }
    }

}

它有点乱,但是很管用!好吧,它创建了虚拟 table…

但后来我得到以下 SQLiteException:

04-04 10:54:12.146 20289-20386/com.example.app E/SQLiteLog: (1) cannot create triggers on virtual tables
04-04 10:54:12.148 20289-20386/com.example.app E/ROOM: Cannot run invalidation tracker. Is the db closed?
    android.database.sqlite.SQLiteException: cannot create triggers on virtual tables (code 1): , while compiling: CREATE TEMP TRIGGER IF NOT EXISTS `room_table_modification_trigger_departments_UPDATE` AFTER UPDATE ON `departments` BEGIN INSERT OR REPLACE INTO room_table_modification_log VALUES(null, 0); END
        at android.database.sqlite.SQLiteConnection.nativePrepareStatement(Native Method)
        at android.database.sqlite.SQLiteConnection.acquirePreparedStatement(SQLiteConnection.java:890)
        at android.database.sqlite.SQLiteConnection.prepare(SQLiteConnection.java:501)
        at android.database.sqlite.SQLiteSession.prepare(SQLiteSession.java:588)
        at android.database.sqlite.SQLiteProgram.<init>(SQLiteProgram.java:58)
        at android.database.sqlite.SQLiteStatement.<init>(SQLiteStatement.java:31)
        at android.database.sqlite.SQLiteDatabase.executeSql(SQLiteDatabase.java:1752)
        at android.database.sqlite.SQLiteDatabase.execSQL(SQLiteDatabase.java:1682)
        at com.example.app.data.FTSSQLiteDatabase.execSQL(FTSSQLiteDatabase.kt:164)
        at android.arch.persistence.room.InvalidationTracker.startTrackingTable(InvalidationTracker.java:204)
        at android.arch.persistence.room.InvalidationTracker.access0(InvalidationTracker.java:62)
        at android.arch.persistence.room.InvalidationTracker.run(InvalidationTracker.java:306)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1162)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:636)
        at java.lang.Thread.run(Thread.java:764)

房间创建 table,但随后尝试在虚拟 table、which is apparently not allowed 上创建触发器。如果我尝试覆盖触发器(即只是阻止它们执行),我猜这会破坏 Room 的很多功能。我假设这就是 Room 一开始不支持 FTS 的原因。

TLDR

所以如果 Room 不支持 FTS(我不能强制它支持),并且不支持 REGEXP(除非我使用 NDK);在使用 Room 时,还有其他方法可以实现搜索吗? FTS 是否是正确的方法(看起来有点矫枉过正),或者是否有其他方法更适合我的用例table?

我可以确认这有效。这很烦人,但它有效。

首先,您需要创建 table。对于初始数据库创建,您可以为此使用 RoomDatabase.Callback

RoomDatabase.Builder<BookDatabase> b=
  Room.databaseBuilder(ctxt.getApplicationContext(), BookDatabase.class,
    DB_NAME);

b.addCallback(new Callback() {
  @Override
  public void onCreate(@NonNull SupportSQLiteDatabase db) {
    super.onCreate(db);

    db.execSQL("CREATE VIRTUAL TABLE booksearch USING fts4(sequence, prose)");
  }
});

BookDatabase books=b.build();

(另外:如果您需要在迁移中对其进行更改,请记住此 table!)

然后您可以为此设置一个 @Dao。所有实际的数据库操作 DAO 方法都需要用 @RawQuery 注释,因为其他所有方法都希望与实体一起使用。而且,由于 @RawQuery 方法仅接受 SupportSQLiteQuery 参数,您可能希望将这些参数包装在创建 SupportSQLiteQuery 对象的其他方法中。

因此,例如,要将数据插入虚拟 table,您可以:

  @RawQuery
  protected abstract long insert(SupportSQLiteQuery queryish);

  void insert(ParagraphEntity entity) {
    insert(new SimpleSQLiteQuery("INSERT INTO booksearch (sequence, prose) VALUES (?, ?)",
      new Object[] {entity.sequence, entity.prose}));
  }

要进行搜索,您可以这样做:

  @RawQuery
  protected abstract List<BookSearchResult> _search(SupportSQLiteQuery query);

  List<BookSearchResult> search(String expr) {
    return _search(query(expr));
  }

  private SimpleSQLiteQuery query(String expr) {
    return new SimpleSQLiteQuery("SELECT sequence, snippet(booksearch) AS snippet FROM booksearch WHERE prose MATCH ? ORDER BY sequence ASC",
      new Object[] {expr});
  }

在这两种情况下,我的 @RawQuery 方法都是 protected 并使用前导 _ 来强调 "these would be private, but you cannot have private abstract methods, so please don't use them, m'kay?".

请注意,您的 FTS 搜索表达式需要遵循 the SQLite FTS documentation

我们终于搞定了,从版本2.1.0-alpha01 Room supports entities with a mapping FTS3 or FTS4 table. For more information and example usage, you can go to their documentation: @Fts3 and @Fts4

开始