enh(issue-search): treat terms in query strings with no explicit operator as MUST (#13025)

The rationale for this decision is the apparent confusion regarding the number of issues that were presented.
For a query such as `foo bar` the natural expectation would be results that contain both `foo` and `bar` not either `foo` or `bar`.
However, for a query such as `+foo bar` the result MUST contain `foo`, while `bar` affects only the scoring of issues.

A downside of such an approach is the inability to fully represent `foo OR bar` as the current implementation imposes that the query must contain atleast one MUST (or MUST NOT) term.

closes #12935

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/13025
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
Shiny Nematoda 2026-07-04 16:05:50 +02:00 committed by Gusted
commit 14c5f0105f
2 changed files with 61 additions and 28 deletions

View file

@ -6,6 +6,7 @@ package internal
import (
"context"
"io"
"slices"
"strconv"
"strings"
"time"
@ -208,7 +209,19 @@ func (o *SearchOptions) WithKeyword(ctx context.Context, keyword string) (err er
return err
}
o.Tokens = tokens
// if the query only contains terms that are SHOULD the results returned are subpar.
// convert them to MUST in this case.
if slices.ContainsFunc(tokens, func(token Token) bool {
return token.Kind != BoolOptShould
}) {
o.Tokens = tokens
} else if len(tokens) != 0 {
o.Tokens = make([]Token, 0, len(tokens))
for _, token := range tokens {
token.Kind = BoolOptMust
o.Tokens = append(o.Tokens, token)
}
}
ids, err := user.GetUserIDsByNames(ctx, userNames, true)
if err != nil {

View file

@ -29,7 +29,7 @@ var testOpts = []testIssueQueryStringOpt{
{
Term: "Hello",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
@ -39,12 +39,12 @@ var testOpts = []testIssueQueryStringOpt{
{
Term: "Hello",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
{
Term: "World",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
@ -54,12 +54,12 @@ var testOpts = []testIssueQueryStringOpt{
{
Term: "Hello",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
{
Term: "World",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
@ -69,12 +69,12 @@ var testOpts = []testIssueQueryStringOpt{
{
Term: "Hello",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
{
Term: "World",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
@ -123,24 +123,44 @@ var testOpts = []testIssueQueryStringOpt{
},
},
},
{
Keyword: "Hello -World",
Results: []Token{
{
Term: "Hello",
Fuzzy: true,
Kind: BoolOptShould,
},
{
Term: "World",
Fuzzy: true,
Kind: BoolOptNot,
},
},
},
{
Keyword: "\"Hello World\"",
Results: []Token{
{
Term: "Hello World",
Fuzzy: false,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
{
Keyword: "+\"Hello World\"",
Keyword: "+\"Hello World\" Hello",
Results: []Token{
{
Term: "Hello World",
Fuzzy: false,
Kind: BoolOptMust,
},
{
Term: "Hello",
Fuzzy: true,
Kind: BoolOptShould,
},
},
},
{
@ -159,7 +179,7 @@ var testOpts = []testIssueQueryStringOpt{
{
Term: "+Hello -World",
Fuzzy: false,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
@ -169,7 +189,7 @@ var testOpts = []testIssueQueryStringOpt{
{
Term: "+Hello",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
@ -179,7 +199,7 @@ var testOpts = []testIssueQueryStringOpt{
{
Term: "\\Hello",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
@ -189,7 +209,7 @@ var testOpts = []testIssueQueryStringOpt{
{
Term: "\"Hello",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
@ -207,7 +227,7 @@ var testOpts = []testIssueQueryStringOpt{
{
Term: "Hello",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
@ -221,7 +241,7 @@ var testOpts = []testIssueQueryStringOpt{
{
Term: " World ",
Fuzzy: false,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
@ -231,7 +251,7 @@ var testOpts = []testIssueQueryStringOpt{
{
Term: "World",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
@ -241,29 +261,29 @@ var testOpts = []testIssueQueryStringOpt{
{
Term: "Best",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
{
Term: "Hello World",
Fuzzy: false,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
{
Term: "Ever",
Fuzzy: true,
Kind: BoolOptShould,
Kind: BoolOptMust,
},
},
},
}
func TestIssueQueryString(t *testing.T) {
var opt SearchOptions
ctx := t.Context()
for _, res := range testOpts {
t.Run(res.Keyword, func(t *testing.T) {
opt := &SearchOptions{}
require.NoError(t, opt.WithKeyword(ctx, res.Keyword))
assert.Equal(t, res.Results, opt.Tokens)
assert.Equal(t, res.Results, opt.Tokens, "failed for keyword `%s`", res.Keyword)
})
}
}
@ -326,7 +346,7 @@ func TestIssueQueryStringWithFilters(t *testing.T) {
Tokens: []Token{
{
Term: "hi",
Kind: BoolOptShould,
Kind: BoolOptMust,
Fuzzy: true,
},
},
@ -368,7 +388,7 @@ func TestIssueQueryStringWithFilters(t *testing.T) {
Tokens: []Token{
{
Term: "test",
Kind: BoolOptShould,
Kind: BoolOptMust,
Fuzzy: true,
},
},
@ -386,7 +406,7 @@ func TestIssueQueryStringWithFilters(t *testing.T) {
Tokens: []Token{
{
Term: "author:",
Kind: BoolOptShould,
Kind: BoolOptMust,
Fuzzy: true,
},
},
@ -402,12 +422,12 @@ func TestIssueQueryStringWithFilters(t *testing.T) {
Tokens: []Token{
{
Term: "author:",
Kind: BoolOptShould,
Kind: BoolOptMust,
Fuzzy: true,
},
{
Term: "test",
Kind: BoolOptShould,
Kind: BoolOptMust,
Fuzzy: true,
},
},
@ -419,7 +439,7 @@ func TestIssueQueryStringWithFilters(t *testing.T) {
Tokens: []Token{
{
Term: "modified:",
Kind: BoolOptShould,
Kind: BoolOptMust,
Fuzzy: true,
},
},