Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> =
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<String>, 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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(""))
}
}