From 17f5ce6ce3e549a5a9b41bc69479ddda7299fa13 Mon Sep 17 00:00:00 2001 From: Shiny Nematoda Date: Sat, 16 May 2026 09:57:51 +0200 Subject: [PATCH] fix(issue-search): single exclude query was erroneosly considered as must (#12589) The bleve indexer included a fast path to consider a single token to be of MUST rather than should. However, the condition missed an additional check and would erroneosly include a NOT as a MUST. This was not spotted by the tests as such exclude queries were usually made along with another term to avoid noise. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12589 Reviewed-by: Gusted --- modules/indexer/issues/bleve/bleve.go | 51 ++++++++++++------- .../indexer/issues/internal/tests/tests.go | 17 +++++++ 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/modules/indexer/issues/bleve/bleve.go b/modules/indexer/issues/bleve/bleve.go index 512788a207..309b2fe6c7 100644 --- a/modules/indexer/issues/bleve/bleve.go +++ b/modules/indexer/issues/bleve/bleve.go @@ -151,28 +151,33 @@ func (b *Indexer) Delete(_ context.Context, ids ...int64) error { return batch.Flush() } -// Search searches for issues by given conditions. -// Returns the matching issue IDs -func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) { +func termQuery(token internal.Token) query.Query { + innerQ := bleve.NewDisjunctionQuery( + inner_bleve.MatchPhraseQuery(token.Term, "title", issueIndexerAnalyzer, token.Fuzzy, 2.0), + inner_bleve.MatchPhraseQuery(token.Term, "content", issueIndexerAnalyzer, token.Fuzzy, 1.0), + inner_bleve.MatchPhraseQuery(token.Term, "comments", issueIndexerAnalyzer, token.Fuzzy, 1.0)) + + if issueID, err := token.ParseIssueReference(); err == nil { + idQuery := inner_bleve.NumericEqualityQuery(issueID, "index") + idQuery.SetBoost(20.0) + innerQ.AddQuery(idQuery) + } + return innerQ +} + +// Create a boolean query with the provided tokens (if any) +func keywordQuery(tokens []internal.Token) *query.BooleanQuery { q := bleve.NewBooleanQuery() - for _, token := range options.Tokens { - innerQ := bleve.NewDisjunctionQuery( - inner_bleve.MatchPhraseQuery(token.Term, "title", issueIndexerAnalyzer, token.Fuzzy, 2.0), - inner_bleve.MatchPhraseQuery(token.Term, "content", issueIndexerAnalyzer, token.Fuzzy, 1.0), - inner_bleve.MatchPhraseQuery(token.Term, "comments", issueIndexerAnalyzer, token.Fuzzy, 1.0)) - - if issueID, err := token.ParseIssueReference(); err == nil { - idQuery := inner_bleve.NumericEqualityQuery(issueID, "index") - idQuery.SetBoost(20.0) - innerQ.AddQuery(idQuery) - } - - if len(options.Tokens) == 1 { - q.AddMust(innerQ) - break - } + // If there is only a single term the user is likely looking for + // a MUST rather than a SHOULD + if len(tokens) == 1 && tokens[0].Kind != internal.BoolOptNot { + q.AddMust(termQuery(tokens[0])) + return q + } + for _, token := range tokens { + innerQ := termQuery(token) switch token.Kind { case internal.BoolOptMust: q.AddMust(innerQ) @@ -183,6 +188,14 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( } } + return q +} + +// Search searches for issues by given conditions. +// Returns the matching issue IDs +func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) { + q := keywordQuery(options.Tokens) + var filters []query.Query if len(options.RepoIDs) > 0 || options.AllPublic { var repoQueries []query.Query diff --git a/modules/indexer/issues/internal/tests/tests.go b/modules/indexer/issues/internal/tests/tests.go index c4a453c9aa..d9428d1af4 100644 --- a/modules/indexer/issues/internal/tests/tests.go +++ b/modules/indexer/issues/internal/tests/tests.go @@ -171,6 +171,23 @@ var cases = []*testIndexerCase{ ExpectedIDs: []int64{1002}, ExpectedTotal: 1, }, + { + Name: "Keyword Exclude Only", + ExtraData: []*internal.IndexerData{ + {ID: 1000, Title: "hello"}, + {ID: 1001, Content: "hello world"}, + {ID: 1002, Comments: []string{"hi", "hello world"}}, + }, + Keyword: "-hello", + SearchOptions: &internal.SearchOptions{ + SortBy: internal.SortByCreatedDesc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + for _, hit := range result.Hits { + assert.NotContains(t, []int64{1000, 1001, 1002}, hit.ID) + } + }, + }, { Name: "Keyword Fuzzy", ExtraData: []*internal.IndexerData{