search the library through FTS5 trigram tables

This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent 46dc6bda0e
commit 954d0eddd2
8 changed files with 1528 additions and 7 deletions
@@ -32,7 +32,11 @@ internal const val ALBUMS_FILTER =
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) "
"AND (:search = '' " +
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(albumArtist), lower(:search)) > 0))) "
internal const val ALBUM_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
@@ -55,7 +59,10 @@ internal const val ARTISTS_FILTER =
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed')) " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) "
"AND (:search = '' " +
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
@@ -69,7 +76,10 @@ internal const val PLAYLISTS_FILTER =
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " +
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId " +
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')) " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) "
"AND (:search = '' " +
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
"WHERE playlist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
@@ -271,6 +271,47 @@ val MIGRATION_18_19 =
}
}
val MIGRATION_19_20 =
object : Migration(19, 20) {
override suspend fun migrate(connection: SQLiteConnection) {
createSearchIndex(connection, "album_search", "albums", listOf("name", "albumArtist"))
createSearchIndex(connection, "artist_search", "artists", listOf("name"))
createSearchIndex(connection, "playlist_search", "playlists", listOf("name"))
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex(
connection: SQLiteConnection,
table: String,
content: String,
columns: List<String>,
) {
connection.execSQL(
"CREATE VIRTUAL TABLE IF NOT EXISTS `$table` USING FTS5(" +
columns.joinToString(", ") { "`$it`" } + ", tokenize=`trigram`, content=`$content`)",
)
val inserted = (listOf("rowid") + columns).joinToString(", ") { "`$it`" }
val values = columns.joinToString(", ") { "NEW.`$it`" }
connection.execSQL(
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_${table}_BEFORE_UPDATE BEFORE UPDATE ON `$content` " +
"BEGIN DELETE FROM `$table` WHERE `rowid`=OLD.`rowid`; END",
)
connection.execSQL(
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_${table}_BEFORE_DELETE BEFORE DELETE ON `$content` " +
"BEGIN DELETE FROM `$table` WHERE `rowid`=OLD.`rowid`; END",
)
connection.execSQL(
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_${table}_AFTER_UPDATE AFTER UPDATE ON `$content` " +
"BEGIN INSERT INTO `$table`($inserted) VALUES (NEW.`rowid`, $values); END",
)
connection.execSQL(
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_${table}_AFTER_INSERT AFTER INSERT ON `$content` " +
"BEGIN INSERT INTO `$table`($inserted) VALUES (NEW.`rowid`, $values); END",
)
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
}
val MIGRATIONS: Array<Migration> =
arrayOf(
MIGRATION_1_2,
@@ -291,4 +332,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_16_17,
MIGRATION_17_18,
MIGRATION_18_19,
MIGRATION_19_20,
)
@@ -4,12 +4,15 @@ import androidx.room3.ColumnTypeConverters
import androidx.room3.Database
import androidx.room3.RoomDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSearch
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistSearch
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DownloadStatusConverter
import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSearch
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKindConverter
@@ -24,9 +27,12 @@ import com.subtracks.data.model.UpNextEntry
Source::class,
SubsonicSource::class,
Artist::class,
ArtistSearch::class,
Album::class,
AlbumSearch::class,
Disc::class,
Playlist::class,
PlaylistSearch::class,
PlaylistSong::class,
Song::class,
QueueEntry::class,
@@ -35,7 +41,7 @@ import com.subtracks.data.model.UpNextEntry
ArtworkSeed::class,
SongDownload::class,
],
version = 19,
version = 20,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
@@ -5,6 +5,8 @@ import androidx.room3.ColumnTypeConverter
import androidx.room3.Embedded
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Fts5
import androidx.room3.FtsOptions
import androidx.room3.Ignore
import androidx.room3.Index
import androidx.room3.PrimaryKey
@@ -351,6 +353,28 @@ data class ArtworkSeed(
@ColumnInfo(defaultValue = "0") val storedAt: Long = 0,
)
@Entity(tableName = "album_search")
@Fts5(contentEntity = Album::class, tokenizer = FtsOptions.TOKENIZER_TRIGRAM)
data class AlbumSearch(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
val name: String,
val albumArtist: String?,
)
@Entity(tableName = "artist_search")
@Fts5(contentEntity = Artist::class, tokenizer = FtsOptions.TOKENIZER_TRIGRAM)
data class ArtistSearch(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
val name: String,
)
@Entity(tableName = "playlist_search")
@Fts5(contentEntity = Playlist::class, tokenizer = FtsOptions.TOKENIZER_TRIGRAM)
data class PlaylistSearch(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
val name: String,
)
class QueueKindConverter {
@ColumnTypeConverter
fun fromQueueKind(kind: QueueKind): String = kind.name
@@ -347,6 +347,106 @@ class LibraryDaoTest {
assertEquals(3, dao.albumsByName(sourceId, 0, "").page().size)
}
@Test
fun searchMatchesInfixCaseInsensitivelyAndFallsBackToAScanBelowThreeCharacters() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(
listOf(
album(sourceId, "a", "Gamma", year = null, starred = null).copy(albumArtist = "The Artist"),
album(sourceId, "b", "Alpha", year = null, starred = null).copy(albumArtist = "Someone Else"),
),
)
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "amm").page().map { it.name })
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "GAM").page().map { it.name })
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "ga").page().map { it.name })
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, 0, "Else").page().map { it.name })
assertTrue(dao.albumsByName(sourceId, 0, "zzz").page().isEmpty())
}
@Test
fun searchIndexFollowsRenamesUpdatesAndDeletes() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(listOf(album(sourceId, "a", "Gamma", year = null, starred = null)))
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "gam").page().map { it.name })
dao.setAlbumStar(sourceId, "a", 7)
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "gam").page().map { it.name })
dao.upsertAlbums(listOf(album(sourceId, "a", "Delta", year = null, starred = null)))
assertTrue(dao.albumsByName(sourceId, 0, "gam").page().isEmpty())
assertEquals(listOf("Delta"), dao.albumsByName(sourceId, 0, "elt").page().map { it.name })
dao.deleteAlbums(sourceId, listOf("a"))
assertTrue(dao.albumsByName(sourceId, 0, "elt").page().isEmpty())
}
@Test
fun deletingASourceClearsItsSearchRows() =
runTest {
val first = source()
val dao = db.libraryDao()
dao.upsertAlbums(listOf(album(first, "a", "Gamma", year = null, starred = null)))
db.sourcesDao().deleteSource(first)
val second = source()
dao.upsertAlbums(listOf(album(second, "a", "Beta", year = null, starred = null)))
assertEquals(listOf("Beta"), dao.albumsByName(second, 0, "eta").page().map { it.name })
assertTrue(dao.albumsByName(second, 0, "gam").page().isEmpty())
}
@Test
fun artistAndPlaylistSearchUseTheirOwnIndices() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "r1", "The Beatles", albumCount = 1)))
dao.upsertPlaylists(listOf(playlist(sourceId, "l1", "Road Trip", created = 0, changed = 0)))
assertEquals(listOf("r1"), dao.artistsByName(sourceId, 0, "eat").page().map { it.id })
assertEquals(listOf("l1"), dao.playlistsByName(sourceId, "rip").page().map { it.id })
assertTrue(dao.artistsByName(sourceId, 0, "rip").page().isEmpty())
assertTrue(dao.playlistsByName(sourceId, "eat").page().isEmpty())
}
@Test
fun aQuoteInTheSearchTermIsTreatedLiterally() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(
listOf(
album(sourceId, "a", "Say \"Hello\"", year = null, starred = null),
album(sourceId, "b", "Plain", year = null, starred = null),
),
)
assertEquals(listOf("Say \"Hello\""), dao.albumsByName(sourceId, 0, "\"Hel").page().map { it.name })
}
@Test
fun searchHandlesANullAlbumArtist() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(listOf(album(sourceId, "a", "Gamma", year = null, starred = null).copy(albumArtist = null)))
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "amm").page().map { it.name })
}
@Test
fun searchRunsThroughTheTrigramIndex() =
runTest {
val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME LIMIT 20 OFFSET 0")
assertTrue(plan, plan.contains("album_search"))
}
private suspend fun plan(sql: String): String =
db.useReaderConnection { connection ->
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->