diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index 9eb01f0cf..1d4116b5d 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -178,7 +178,9 @@ func buildFTS5Query(userInput string) string { tokens[i] = t + "*" } - result = strings.Join(tokens, " ") + // Use explicit AND between tokens — FTS5's implicit AND (space-separated) + // doesn't work correctly with parenthesized OR groups from processPunctuatedWords. + result = strings.Join(tokens, " AND ") for i, phrase := range phrases { placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i) diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index 337d54201..d0e26c8e3 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -17,32 +17,33 @@ var _ = DescribeTable("buildFTS5Query", Entry("returns empty string for empty input", "", ""), Entry("returns empty string for whitespace-only input", " ", ""), Entry("appends * to a single word for prefix matching", "beatles", "beatles*"), - Entry("appends * to each word for prefix matching", "abbey road", "abbey* road*"), + Entry("appends * to each word for prefix matching", "abbey road", "abbey* AND road*"), Entry("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`), Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"), - Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* or* not* near*"), - Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* col* val*"), - Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" abbey*`), - Entry("handles prefix with multiple words", "beat* abbey", "beat* abbey*"), - Entry("collapses multiple spaces", "abbey road", "abbey* road*"), + Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* AND or* AND not* AND near*"), + Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* AND col* AND val*"), + Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND abbey*`), + Entry("handles prefix with multiple words", "beat* abbey", "beat* AND abbey*"), + Entry("collapses multiple spaces", "abbey road", "abbey* AND road*"), Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"), - Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* oliv*"), + Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* AND oliv*"), Entry("strips standalone *", "*", ""), - Entry("strips apostrophe from input", "Guns N' Roses", "Guns* N* Roses*"), + Entry("strips apostrophe from input", "Guns N' Roses", "Guns* AND N* AND Roses*"), Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`), Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`), Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`), Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`), Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`), - Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* of* ("a ha" OR aha*)`), - Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* roll* vol* 2*"), - Entry("preserves unicode characters with diacritics", "Björk début", "Björk* début*"), + Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`), + Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`), + Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"), + Entry("preserves unicode characters with diacritics", "Björk début", "Björk* AND début*"), Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`), Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`), - Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* of* "R E M"`), + Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`), Entry("collapses two-letter abbreviation", "U.K.", `"U K"`), - Entry("does not collapse single letter surrounded by words", "I am fine", "I* am* fine*"), - Entry("does not collapse single standalone letter", "A test", "A* test*"), + Entry("does not collapse single letter surrounded by words", "I am fine", "I* AND am* AND fine*"), + Entry("does not collapse single standalone letter", "A test", "A* AND test*"), Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`), Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`), Entry("returns empty string for punctuation-only input", "!!!!!!!", ""),