diff --git a/app/src/androidTest/java/com/theveloper/pixelplay/data/database/MusicDaoTest.kt b/app/src/androidTest/java/com/theveloper/pixelplay/data/database/MusicDaoTest.kt index b9045e6fe5..e3de223ddb 100644 --- a/app/src/androidTest/java/com/theveloper/pixelplay/data/database/MusicDaoTest.kt +++ b/app/src/androidTest/java/com/theveloper/pixelplay/data/database/MusicDaoTest.kt @@ -204,4 +204,31 @@ class MusicDaoTest { val titles = results.map { it.title }.sorted() assertEquals(listOf("Cool Song", "Coolest Song Ever"), titles) } + + /** + * Regression test for a real bug found on-device: FTS4 MATCH queries built with the + * literal "AND" keyword only behave as a boolean operator on SQLite builds compiled + * with SQLITE_ENABLE_FTS3_PARENTHESIS. Without it "AND" is parsed as an ordinary + * search term, so a two-word query would only match rows that literally contained + * the word "and" - silently breaking multi-word search. This runs against the real + * on-device/emulator SQLite engine (unlike a plain string-building unit test), which + * is what actually caught the bug. + */ + @Test + @Throws(Exception::class) + fun searchSongs_multiWordQuery_matchesSongWithoutLiteralAndKeyword() = runTest { + // Insert the referenced artist/album first: songs has a foreign key to both. + musicDao.insertArtists(listOf(createArtistEntity(101L, "Some Artist"))) + musicDao.insertAlbums(listOf(createAlbumEntity(201L, "Album X"))) + val songs = listOf( + createSongEntity(1L, "Qué ganas de comerte", "Some Artist", "Album X", "/p1/s1.mp3"), + createSongEntity(2L, "Completely unrelated title", "Other Artist", "Album Y", "/p2/s2.mp3") + ) + musicDao.insertSongs(songs) + + val results = musicDao.searchSongs("que ganas", emptyList(), false).first() + + assertEquals(1, results.size) + assertEquals("Qué ganas de comerte", results[0].title) + } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt index 8a141e350e..5602ccfb77 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt @@ -14,32 +14,40 @@ import kotlinx.coroutines.flow.combine private val SONG_SEARCH_QUERY_TOKEN_REGEX = Regex("""[\p{L}\p{N}]+""") private const val EMPTY_SONG_SEARCH_MATCH_QUERY = "pixelplayemptyquery*" +private const val MAX_SONG_SEARCH_QUERY_TOKENS = 6 -private fun buildSongTitleSearchMatchQuery(query: String): String { - val tokens = SONG_SEARCH_QUERY_TOKEN_REGEX +private fun tokenizeSongSearchQuery(query: String): List = + SONG_SEARCH_QUERY_TOKEN_REGEX .findAll(query) .map { it.value.trim() } .filter { it.isNotEmpty() } - .take(6) + .take(MAX_SONG_SEARCH_QUERY_TOKENS) .toList() +/** + * Builds an FTS4 MATCH expression requiring every token to match as a prefix + * within [column] (or anywhere, if [column] is null). + * + * Tokens are joined with a plain space, i.e. FTS's *implicit* AND, rather than + * the literal "AND" keyword: that keyword requires SQLite to be compiled with + * SQLITE_ENABLE_FTS3_PARENTHESIS, which not every Android build has. Without + * it, "AND" is parsed as an ordinary search term instead of a boolean + * operator, so a query like "que* AND ganas*" would only match rows that + * literally contain the word "and" - silently breaking every multi-word + * search on devices without that extension. + */ +private fun buildFtsMatchQuery(tokens: List, column: String? = null): String { if (tokens.isEmpty()) return EMPTY_SONG_SEARCH_MATCH_QUERY - - return tokens.joinToString(separator = " AND ") { "title:${it}*" } + return tokens.joinToString(separator = " ") { token -> + if (column != null) "$column:$token*" else "$token*" + } } -private fun buildSongSearchMatchQuery(query: String): String { - val tokens = SONG_SEARCH_QUERY_TOKEN_REGEX - .findAll(query) - .map { it.value.trim() } - .filter { it.isNotEmpty() } - .take(6) - .toList() +internal fun buildSongTitleSearchMatchQuery(query: String): String = + buildFtsMatchQuery(tokenizeSongSearchQuery(query), column = "title") - if (tokens.isEmpty()) return EMPTY_SONG_SEARCH_MATCH_QUERY - - return tokens.joinToString(separator = " AND ") { "${it}*" } -} +internal fun buildSongSearchMatchQuery(query: String): String = + buildFtsMatchQuery(tokenizeSongSearchQuery(query)) private const val SONG_DETAIL_PROJECTION = """ songs.id AS id, diff --git a/app/src/test/java/com/theveloper/pixelplay/data/database/MusicDaoQueryBuilderTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/database/MusicDaoQueryBuilderTest.kt new file mode 100644 index 0000000000..a96c21694b --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/database/MusicDaoQueryBuilderTest.kt @@ -0,0 +1,58 @@ +package com.theveloper.pixelplay.data.database + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Regression coverage for the FTS4 MATCH query builders. These must use + * implicit AND (plain spaces) rather than the literal "AND" keyword, which + * only behaves as a boolean operator on SQLite builds compiled with + * SQLITE_ENABLE_FTS3_PARENTHESIS - not guaranteed on every Android device + * (confirmed absent on at least one real device during manual testing). + */ +class MusicDaoQueryBuilderTest { + + @Test + fun buildSongSearchMatchQuery_singleWord_returnsSinglePrefixTerm() { + assertEquals("que*", buildSongSearchMatchQuery("que")) + } + + @Test + fun buildSongSearchMatchQuery_multipleWords_joinsWithImplicitAndNotLiteralKeyword() { + val result = buildSongSearchMatchQuery("que ganas") + + assertEquals("que* ganas*", result) + assertEquals(false, result.contains("AND", ignoreCase = true)) + } + + @Test + fun buildSongSearchMatchQuery_blankQuery_returnsEmptyQuerySentinel() { + assertEquals("pixelplayemptyquery*", buildSongSearchMatchQuery("")) + assertEquals("pixelplayemptyquery*", buildSongSearchMatchQuery(" ")) + } + + @Test + fun buildSongSearchMatchQuery_capsAtSixTokens() { + val result = buildSongSearchMatchQuery("one two three four five six seven eight") + + assertEquals("one* two* three* four* five* six*", result) + } + + @Test + fun buildSongTitleSearchMatchQuery_singleWord_scopesToTitleColumn() { + assertEquals("title:que*", buildSongTitleSearchMatchQuery("que")) + } + + @Test + fun buildSongTitleSearchMatchQuery_multipleWords_joinsWithImplicitAnd() { + val result = buildSongTitleSearchMatchQuery("que ganas") + + assertEquals("title:que* title:ganas*", result) + assertEquals(false, result.contains("AND", ignoreCase = true)) + } + + @Test + fun buildSongTitleSearchMatchQuery_blankQuery_returnsEmptyQuerySentinel() { + assertEquals("pixelplayemptyquery*", buildSongTitleSearchMatchQuery("")) + } +}