feat: preserve web-search operators and escape participant patterns in unibox search

This commit is contained in:
Matthew Meszaros
2026-09-17 04:18:08 -07:00
parent ebbe432d2c
commit cc5925fe44
3 changed files with 38 additions and 11 deletions
+24 -5
View File
@@ -519,7 +519,7 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params *
// declares it, or the index is not used.
q := *params.Subject
webPos, addrPos := argPos, argPos+1
args = append(args, q, q)
args = append(args, q, escapeLikePattern(q))
argPos += 2
match := fmt.Sprintf(`ue.search_tsv @@ websearch_to_tsquery('english', $%d)
@@ -536,9 +536,9 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params *
// Participants. The stored header is "Display Name (addr)", so one
// substring match covers searching by either.
match += fmt.Sprintf(`
OR EXISTS (SELECT 1 FROM unnest(ue.from_addr) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%')
OR EXISTS (SELECT 1 FROM unnest(ue.to_addr) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%')
OR EXISTS (SELECT 1 FROM unnest(ue.cc) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%')`,
OR EXISTS (SELECT 1 FROM unnest(ue.from_addr) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%' ESCAPE '\')
OR EXISTS (SELECT 1 FROM unnest(ue.to_addr) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%' ESCAPE '\')
OR EXISTS (SELECT 1 FROM unnest(ue.cc) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%' ESCAPE '\')`,
addrPos, addrPos, addrPos)
inner += ` AND (` + match + `)`
@@ -1494,6 +1494,9 @@ func (r *uniboxRepository) scanGrounding(ctx context.Context, query string, orgI
// so adding a word narrows rather than widens.
func prefixTSQuery(input string) string {
const maxTerms = 8
if hasWebSearchSyntax(input) {
return ""
}
cleaned := strings.Map(func(r rune) rune {
switch {
@@ -1509,10 +1512,26 @@ func prefixTSQuery(input string) string {
return ""
}
if len(terms) > maxTerms {
terms = terms[:maxTerms]
return ""
}
for i, t := range terms {
terms[i] = t + ":*"
}
return strings.Join(terms, " & ")
}
func hasWebSearchSyntax(input string) bool {
if strings.Contains(input, `"`) {
return true
}
for _, term := range strings.Fields(input) {
if strings.HasPrefix(term, "-") || strings.EqualFold(term, "OR") {
return true
}
}
return false
}
func escapeLikePattern(input string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(input)
}
+13 -5
View File
@@ -19,8 +19,10 @@ func TestPrefixTSQuery(t *testing.T) {
// Every one of these makes to_tsquery raise when passed through raw.
{"operators are stripped", "a & b | c", "a:* & b:* & c:*"},
{"unbalanced bracket", "re: (urgent", "re:* & urgent:*"},
{"negation and colons", "!foo:bar", "foo:* & bar:*"},
{"quotes", `"exact phrase"`, "exact:* & phrase:*"},
{"punctuation and colons", "!foo:bar", "foo:* & bar:*"},
{"negation keeps web-search semantics", "foo -bar", ""},
{"quotes keep web-search semantics", `"exact phrase"`, ""},
{"OR keeps web-search semantics", "foo OR bar", ""},
{"a lone operator has nothing to search", "&&&", ""},
{"empty", "", ""},
@@ -37,11 +39,17 @@ func TestPrefixTSQuery(t *testing.T) {
}
}
// A pasted paragraph must not become a 200-term query the planner has to walk.
// Long input stays on websearch_to_tsquery instead of being broadened by a truncated prefix query.
func TestPrefixTSQueryCapsTerms(t *testing.T) {
got := prefixTSQuery("one two three four five six seven eight nine ten eleven")
want := "one:* & two:* & three:* & four:* & five:* & six:* & seven:* & eight:*"
want := ""
if got != want {
t.Fatalf("prefixTSQuery capped = %q, want %q", got, want)
t.Fatalf("prefixTSQuery long input = %q, want %q", got, want)
}
}
func TestEscapeLikePattern(t *testing.T) {
if got, want := escapeLikePattern(`100%_done\later`), `100\%\_done\\later`; got != want {
t.Fatalf("escapeLikePattern = %q, want %q", got, want)
}
}
@@ -304,7 +304,7 @@ export function ConversationList({
// The box searches people, subject and message body, and naming
// that is the difference between it looking broken and looking
// useful: nobody tries an address in a box labelled "Search inbox".
placeholder={`Search ${scopeLabel.toLowerCase()} — name, address, or any word`}
placeholder={`Search ${scopeLabel.toLowerCase()}: name, address, or any word`}
title={'Searches the sender, recipients, subject and message body. "quoted phrases", OR and -exclude work.'}
className="flex-1 min-w-0 h-full bg-transparent text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none"
/>