From f73e073e4afd97924e8395cf26802c7550e365ae Mon Sep 17 00:00:00 2001 From: PonceGL Date: Wed, 5 Aug 2026 21:04:12 -0600 Subject: [PATCH] fix(search): use implicit AND in FTS queries, not the AND keyword buildSongSearchMatchQuery/buildSongTitleSearchMatchQuery joined query tokens with the literal " AND " keyword (e.g. "que* AND ganas*"). That keyword only behaves as a boolean operator on SQLite builds compiled with SQLITE_ENABLE_FTS3_PARENTHESIS - not guaranteed on every Android device. Without it, "AND" is parsed as an ordinary search term, so a multi-word query would only match rows that literally contained the word "and", silently breaking multi-word search entirely on affected devices. Confirmed on a real device (Galaxy S25 Ultra, SQLite 3.44.5, no FTS3_PARENTHESIS support): searching a two-word query against a title that doesn't contain "and" returned zero results, regardless of case or accents, while the exact same content was trivially findable via a single-token query or via the always-available implicit-AND syntax (space-separated terms, no keyword). Fix: join tokens with a plain space instead. Implicit AND is base FTS3/4 syntax, supported unconditionally on every SQLite build. Also extracts the shared tokenization logic into one helper (buildFtsMatchQuery) to remove the duplication between the two query builders. Adds MusicDaoQueryBuilderTest (unit, verifies the query string shape) and a MusicDaoTest regression case that runs the real query against a real FTS4 table - which is what actually caught this, since a plain string-comparison test wouldn't exercise the SQLite engine at all. --- .../pixelplay/data/database/MusicDaoTest.kt | 27 +++++++++ .../pixelplay/data/database/MusicDao.kt | 40 ++++++++----- .../data/database/MusicDaoQueryBuilderTest.kt | 58 +++++++++++++++++++ 3 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/database/MusicDaoQueryBuilderTest.kt 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("")) + } +}