feat(issue-search): support query syntax (#9109)

List of currently supported filters:

- `is:open` (or `-is:closed`)
- `is:closed` (or `-is:open`)
- `is:all`
- `author:<username>`
- `assignee:<username>`
- `review:<username>`
- `mentions:<username>`
- `modified:[>|<]<date>`, where `<date>` is the last update date.
- `sort:<by>:[asc|desc]`, where `<by>` is among
	- created
	- comments
	- updated
	- deadline

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9109
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
Reviewed-by: Ellen Εμιλία Άννα Zscheile <fogti@noreply.codeberg.org>
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Shiny Nematoda <snematoda.751k2@aleeas.com>
Co-committed-by: Shiny Nematoda <snematoda.751k2@aleeas.com>
This commit is contained in:
Shiny Nematoda 2025-11-19 16:05:42 +01:00 committed by Gusted
parent 4d0c7db6cd
commit 255ed593d3
26 changed files with 870 additions and 296 deletions

View file

@ -9,6 +9,7 @@ import (
indexer_internal "forgejo.org/modules/indexer/internal"
inner_bleve "forgejo.org/modules/indexer/internal/bleve"
"forgejo.org/modules/indexer/issues/internal"
"forgejo.org/modules/optional"
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
@ -154,39 +155,36 @@ func (b *Indexer) Delete(_ context.Context, ids ...int64) error {
// 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) {
var queries []query.Query
q := bleve.NewBooleanQuery()
tokens, err := options.Tokens()
if err != nil {
return nil, err
}
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 len(tokens) > 0 {
q := bleve.NewBooleanQuery()
for _, token := range 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)
}
switch token.Kind {
case internal.BoolOptMust:
q.AddMust(innerQ)
case internal.BoolOptShould:
q.AddShould(innerQ)
case internal.BoolOptNot:
q.AddMustNot(innerQ)
}
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
}
switch token.Kind {
case internal.BoolOptMust:
q.AddMust(innerQ)
case internal.BoolOptShould:
q.AddShould(innerQ)
case internal.BoolOptNot:
q.AddMustNot(innerQ)
}
queries = append(queries, q)
}
var filters []query.Query
if len(options.RepoIDs) > 0 || options.AllPublic {
var repoQueries []query.Query
for _, repoID := range options.RepoIDs {
@ -195,7 +193,7 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
if options.AllPublic {
repoQueries = append(repoQueries, inner_bleve.BoolFieldQuery(true, "is_public"))
}
queries = append(queries, bleve.NewDisjunctionQuery(repoQueries...))
filters = append(filters, bleve.NewDisjunctionQuery(repoQueries...))
}
if options.PriorityRepoID.Has() {
@ -203,41 +201,36 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
eq.SetBoost(10.0)
meh := bleve.NewMatchAllQuery()
meh.SetBoost(0)
should := bleve.NewDisjunctionQuery(eq, meh)
queries = append(queries, should)
q.AddShould(bleve.NewDisjunctionQuery(eq, meh))
}
if options.IsPull.Has() {
queries = append(queries, inner_bleve.BoolFieldQuery(options.IsPull.Value(), "is_pull"))
filters = append(filters, inner_bleve.BoolFieldQuery(options.IsPull.Value(), "is_pull"))
}
if options.IsClosed.Has() {
queries = append(queries, inner_bleve.BoolFieldQuery(options.IsClosed.Value(), "is_closed"))
filters = append(filters, inner_bleve.BoolFieldQuery(options.IsClosed.Value(), "is_closed"))
}
if options.NoLabelOnly {
queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_label"))
filters = append(filters, inner_bleve.BoolFieldQuery(true, "no_label"))
} else {
if len(options.IncludedLabelIDs) > 0 {
var includeQueries []query.Query
for _, labelID := range options.IncludedLabelIDs {
includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids"))
}
queries = append(queries, bleve.NewConjunctionQuery(includeQueries...))
filters = append(filters, includeQueries...)
} else if len(options.IncludedAnyLabelIDs) > 0 {
var includeQueries []query.Query
for _, labelID := range options.IncludedAnyLabelIDs {
includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids"))
}
queries = append(queries, bleve.NewDisjunctionQuery(includeQueries...))
filters = append(filters, bleve.NewDisjunctionQuery(includeQueries...))
}
if len(options.ExcludedLabelIDs) > 0 {
var excludeQueries []query.Query
for _, labelID := range options.ExcludedLabelIDs {
q := bleve.NewBooleanQuery()
q.AddMustNot(inner_bleve.NumericEqualityQuery(labelID, "label_ids"))
excludeQueries = append(excludeQueries, q)
}
queries = append(queries, bleve.NewConjunctionQuery(excludeQueries...))
}
}
@ -246,48 +239,41 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
for _, milestoneID := range options.MilestoneIDs {
milestoneQueries = append(milestoneQueries, inner_bleve.NumericEqualityQuery(milestoneID, "milestone_id"))
}
queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...))
filters = append(filters, bleve.NewDisjunctionQuery(milestoneQueries...))
}
if options.ProjectID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id"))
}
if options.ProjectColumnID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id"))
}
if options.PosterID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.PosterID.Value(), "poster_id"))
}
if options.AssigneeID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.AssigneeID.Value(), "assignee_id"))
}
if options.MentionID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.MentionID.Value(), "mention_ids"))
}
if options.ReviewedID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ReviewedID.Value(), "reviewed_ids"))
}
if options.ReviewRequestedID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.ReviewRequestedID.Value(), "review_requested_ids"))
}
if options.SubscriberID.Has() {
queries = append(queries, inner_bleve.NumericEqualityQuery(options.SubscriberID.Value(), "subscriber_ids"))
for key, val := range map[string]optional.Option[int64]{
"project_id": options.ProjectID,
"project_board_id": options.ProjectColumnID,
"poster_id": options.PosterID,
"assignee_id": options.AssigneeID,
"mention_ids": options.MentionID,
"reviewed_ids": options.ReviewedID,
"review_requested_ids": options.ReviewRequestedID,
"subscriber_ids": options.SubscriberID,
} {
if val.Has() {
filters = append(filters, inner_bleve.NumericEqualityQuery(val.Value(), key))
}
}
if options.UpdatedAfterUnix.Has() || options.UpdatedBeforeUnix.Has() {
queries = append(queries, inner_bleve.NumericRangeInclusiveQuery(
filters = append(filters, inner_bleve.NumericRangeInclusiveQuery(
options.UpdatedAfterUnix,
options.UpdatedBeforeUnix,
"updated_unix"))
}
var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...)
if len(queries) == 0 {
switch len(filters) {
case 0:
break
case 1:
q.Filter = filters[0]
default:
q.Filter = bleve.NewConjunctionQuery(filters...)
}
var indexerQuery query.Query = q
if q.Must == nil && q.MustNot == nil && q.Should == nil && len(filters) == 0 {
indexerQuery = bleve.NewMatchAllQuery()
}

View file

@ -5,7 +5,6 @@ package db
import (
"context"
"strconv"
"forgejo.org/models/db"
issue_model "forgejo.org/models/issues"
@ -54,36 +53,34 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
cond := builder.NewCond()
var priorityIssueIndex int64
if options.Keyword != "" {
if len(options.Tokens) != 0 {
repoCond := builder.In("repo_id", options.RepoIDs)
if len(options.RepoIDs) == 1 {
repoCond = builder.Eq{"repo_id": options.RepoIDs[0]}
}
subQuery := builder.Select("id").From("issue").Where(repoCond)
cond = builder.Or(
db.BuildCaseInsensitiveLike("issue.name", options.Keyword),
db.BuildCaseInsensitiveLike("issue.content", options.Keyword),
builder.In("issue.id", builder.Select("issue_id").
From("comment").
Where(builder.And(
builder.Eq{"type": issue_model.CommentTypeComment},
builder.In("issue_id", subQuery),
db.BuildCaseInsensitiveLike("content", options.Keyword),
)),
),
)
term := options.Keyword
if term[0] == '#' || term[0] == '!' {
term = term[1:]
}
if issueID, err := strconv.ParseInt(term, 10, 64); err == nil {
for _, token := range options.Tokens {
cond = builder.Or(
builder.Eq{"`index`": issueID},
cond,
db.BuildCaseInsensitiveLike("issue.name", token.Term),
db.BuildCaseInsensitiveLike("issue.content", token.Term),
builder.In("issue.id", builder.Select("issue_id").
From("comment").
Where(builder.And(
builder.Eq{"type": issue_model.CommentTypeComment},
builder.In("issue_id", subQuery),
db.BuildCaseInsensitiveLike("content", token.Term),
)),
),
)
priorityIssueIndex = issueID
if ref, err := token.ParseIssueReference(); err != nil {
cond = builder.Or(
builder.Eq{"`index`": ref},
cond,
)
priorityIssueIndex = ref
}
}
}

View file

@ -4,14 +4,15 @@
package issues
import (
"context"
"forgejo.org/models/db"
issues_model "forgejo.org/models/issues"
"forgejo.org/modules/optional"
)
func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
func ToSearchOptions(ctx context.Context, keyword string, opts *issues_model.IssuesOptions) *SearchOptions {
searchOpt := &SearchOptions{
Keyword: keyword,
RepoIDs: opts.RepoIDs,
AllPublic: opts.AllPublic,
IsPull: opts.IsPull,
@ -103,5 +104,7 @@ func ToSearchOptions(keyword string, opts *issues_model.IssuesOptions) *SearchOp
searchOpt.SortBy = SortByUpdatedDesc
}
_ = searchOpt.WithKeyword(ctx, keyword)
return searchOpt
}

View file

@ -149,14 +149,9 @@ func (b *Indexer) Delete(ctx context.Context, ids ...int64) error {
func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
query := elastic.NewBoolQuery()
tokens, err := options.Tokens()
if err != nil {
return nil, err
}
if len(tokens) > 0 {
if len(options.Tokens) != 0 {
q := elastic.NewBoolQuery()
for _, token := range tokens {
for _, token := range options.Tokens {
innerQ := elastic.NewMultiMatchQuery(token.Term, "content", "comments").FieldWithBoost("title", 2.0).TieBreaker(0.5)
if token.Fuzzy {
// If the term is not a phrase use fuzziness set to AUTO

View file

@ -312,7 +312,7 @@ func ParseSortBy(sortBy string, defaultSortBy internal.SortBy) internal.SortBy {
func SearchIssues(ctx context.Context, opts *SearchOptions) ([]int64, int64, error) {
indexer := *globalIndexer.Load()
if opts.Keyword == "" {
if len(opts.Tokens) == 0 {
// This is a conservative shortcut.
// If the keyword is empty, db has better (at least not worse) performance to filter issues.
// When the keyword is empty, it tends to listing rather than searching issues.

View file

@ -47,44 +47,48 @@ func TestDBSearchIssues(t *testing.T) {
func searchIssueWithKeyword(t *testing.T) {
tests := []struct {
opts SearchOptions
keyword string
opts *SearchOptions
expectedIDs []int64
}{
{
SearchOptions{
Keyword: "issue2",
"issue2",
&SearchOptions{
RepoIDs: []int64{1},
},
[]int64{2},
},
{
SearchOptions{
Keyword: "first",
"first",
&SearchOptions{
RepoIDs: []int64{1},
},
[]int64{1},
},
{
SearchOptions{
Keyword: "for",
"for",
&SearchOptions{
RepoIDs: []int64{1},
},
[]int64{11, 5, 3, 2, 1},
},
{
SearchOptions{
Keyword: "good",
"good",
&SearchOptions{
RepoIDs: []int64{1},
},
[]int64{1},
},
}
ctx := t.Context()
for _, test := range tests {
issueIDs, _, err := SearchIssues(t.Context(), &test.opts)
require.NoError(t, test.opts.WithKeyword(ctx, test.keyword))
issueIDs, _, err := SearchIssues(ctx, test.opts)
require.NoError(t, err)
assert.Equal(t, test.expectedIDs, issueIDs, test.opts.Keyword)
assert.Equal(t, test.expectedIDs, issueIDs, test.keyword)
}
}
@ -152,7 +156,7 @@ func searchIssueByID(t *testing.T) {
},
{
// NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it will set AssigneeID to 0 when it is passed as -1.
opts: *ToSearchOptions("", &issues.IssuesOptions{AssigneeID: -1}),
opts: *ToSearchOptions(t.Context(), "", &issues.IssuesOptions{AssigneeID: -1}),
expectedIDs: []int64{22, 21, 16, 15, 14, 13, 12, 11, 20, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2},
},
{

View file

@ -73,7 +73,7 @@ type SearchResult struct {
// It can handle almost all cases, if there is an exception, we can add a new field, like NoLabelOnly.
// Unfortunately, we still use db for the indexer and have to convert between db.NoConditionID and nil for legacy reasons.
type SearchOptions struct {
Keyword string // keyword to search
Tokens []Token
RepoIDs []int64 // repository IDs which the issues belong to
AllPublic bool // if include all public repositories
@ -149,3 +149,28 @@ const (
// but what if the issue belongs to multiple projects?
// Since it's unsupported to search issues with keyword in project page, we don't need to support it.
)
func (s SortBy) ToIssueSort() string {
switch s {
case SortByScore:
return "relevance"
case SortByCreatedDesc:
return "latest"
case SortByCreatedAsc:
return "oldest"
case SortByUpdatedDesc:
return "recentupdate"
case SortByUpdatedAsc:
return "leastupdate"
case SortByCommentsDesc:
return "mostcomment"
case SortByCommentsAsc:
return "leastcomment"
case SortByDeadlineAsc:
return "nearduedate"
case SortByDeadlineDesc:
return "farduedate"
}
return "latest"
}

View file

@ -4,9 +4,15 @@
package internal
import (
"context"
"io"
"strconv"
"strings"
"time"
"forgejo.org/models/user"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
)
type BoolOpt int
@ -23,6 +29,11 @@ type Token struct {
Fuzzy bool
}
// Helper function to check if the term starts with a prefix.
func (tk *Token) IsOf(prefix string) bool {
return strings.HasPrefix(tk.Term, prefix) && len(tk.Term) > len(prefix)
}
func (tk *Token) ParseIssueReference() (int64, error) {
term := tk.Term
if len(term) > 1 && (term[0] == '#' || term[0] == '!') {
@ -102,23 +113,159 @@ nextEnd:
return tk, err
}
// Tokenize the keyword
func (o *SearchOptions) Tokens() (tokens []Token, err error) {
if o.Keyword == "" {
return nil, nil
type userFilter int
const (
userFilterAuthor userFilter = iota
userFilterAssign
userFilterMention
userFilterReview
)
// Parses the keyword and sets the
func (o *SearchOptions) WithKeyword(ctx context.Context, keyword string) (err error) {
if keyword == "" {
return nil
}
in := strings.NewReader(o.Keyword)
in := strings.NewReader(keyword)
it := Tokenizer{in: in}
var (
tokens []Token
userNames []string
userFilter []userFilter
)
for token, err := it.next(); err == nil; token, err = it.next() {
if token.Term != "" {
if token.Term == "" {
continue
}
// For an exact search (wrapped in quotes)
// push the token to the list.
if !token.Fuzzy {
tokens = append(tokens, token)
continue
}
// Otherwise, try to match the token with a preset filter.
switch {
// is:open => open & -is:open => closed
case token.Term == "is:open":
o.IsClosed = optional.Some(token.Kind == BoolOptNot)
// Similarly, is:closed & -is:closed
case token.Term == "is:closed":
o.IsClosed = optional.Some(token.Kind != BoolOptNot)
// The rest of the presets MUST NOT be a negation.
case token.Kind == BoolOptNot:
tokens = append(tokens, token)
// is:all: Do not consider -is:all.
case token.Term == "is:all":
o.IsClosed = optional.None[bool]()
// sort:<by>:[ asc | desc ],
case token.IsOf("sort:"):
o.SortBy = parseSortBy(token.Term[5:])
// modified:[ < | > ]<date>.
// for example, modified:>2025-08-29
case token.IsOf("modified:"):
switch token.Term[9] {
case '>':
o.UpdatedAfterUnix = toUnix(token.Term[10:])
case '<':
o.UpdatedBeforeUnix = toUnix(token.Term[10:])
default:
t := toUnix(token.Term[9:])
o.UpdatedAfterUnix = t
o.UpdatedBeforeUnix = t
}
// for user filter's
// append the names and roles
case token.IsOf("author:"):
userNames = append(userNames, token.Term[7:])
userFilter = append(userFilter, userFilterAuthor)
case token.IsOf("assignee:"):
userNames = append(userNames, token.Term[9:])
userFilter = append(userFilter, userFilterAssign)
case token.IsOf("review:"):
userNames = append(userNames, token.Term[7:])
userFilter = append(userFilter, userFilterReview)
case token.IsOf("mentions:"):
userNames = append(userNames, token.Term[9:])
userFilter = append(userFilter, userFilterMention)
default:
tokens = append(tokens, token)
}
}
if err != nil && err != io.EOF {
return nil, err
return err
}
return tokens, nil
o.Tokens = tokens
ids, err := user.GetUserIDsByNames(ctx, userNames, true)
if err != nil {
return err
}
for i, id := range ids {
// Skip all invalid IDs.
// Hopefully this won't be too astonishing for the user.
if id <= 0 {
continue
}
val := optional.Some(id)
switch userFilter[i] {
case userFilterAuthor:
o.PosterID = val
case userFilterAssign:
o.AssigneeID = val
case userFilterReview:
o.ReviewedID = val
case userFilterMention:
o.MentionID = val
}
}
return nil
}
func toUnix(value string) optional.Option[int64] {
time, err := time.Parse(time.DateOnly, value)
if err != nil {
log.Warn("Failed to parse date '%v'", err)
return optional.None[int64]()
}
return optional.Some(time.Unix())
}
func parseSortBy(sortBy string) SortBy {
switch sortBy {
case "created:asc":
return SortByCreatedAsc
case "created:desc":
return SortByCreatedDesc
case "comments:asc":
return SortByCommentsAsc
case "comments:desc":
return SortByCommentsDesc
case "updated:asc":
return SortByUpdatedAsc
case "updated:desc":
return SortByUpdatedDesc
case "deadline:asc":
return SortByDeadlineAsc
case "deadline:desc":
return SortByDeadlineDesc
default:
return SortByScore
}
}

View file

@ -4,8 +4,13 @@
package internal
import (
"context"
"testing"
"forgejo.org/models/unittest"
"forgejo.org/models/user"
"forgejo.org/modules/optional"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -252,12 +257,177 @@ var testOpts = []testIssueQueryStringOpt{
func TestIssueQueryString(t *testing.T) {
var opt SearchOptions
ctx := t.Context()
for _, res := range testOpts {
t.Run(opt.Keyword, func(t *testing.T) {
opt.Keyword = res.Keyword
tokens, err := opt.Tokens()
require.NoError(t, err)
assert.Equal(t, res.Results, tokens)
t.Run(res.Keyword, func(t *testing.T) {
require.NoError(t, opt.WithKeyword(ctx, res.Keyword))
assert.Equal(t, res.Results, opt.Tokens)
})
}
}
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
func TestIssueQueryStringWithFilters(t *testing.T) {
// we don't need all the fixures
// insert only one single test user
require.NoError(t, user.CreateUser(t.Context(), &user.User{
ID: 2,
Name: "test",
LowerName: "test",
Email: "test@localhost",
}))
for _, c := range []struct {
Keyword string
Opts *SearchOptions
}{
// Generic Cases
{
Keyword: "modified:>2025-08-28",
Opts: &SearchOptions{
UpdatedAfterUnix: optional.Some(int64(1756339200)),
},
},
{
Keyword: "modified:<2025-08-28",
Opts: &SearchOptions{
UpdatedBeforeUnix: optional.Some(int64(1756339200)),
},
},
{
Keyword: "modified:>2025-08-28 modified:<2025-08-28",
Opts: &SearchOptions{
UpdatedAfterUnix: optional.Some(int64(1756339200)),
UpdatedBeforeUnix: optional.Some(int64(1756339200)),
},
},
{
Keyword: "modified:2025-08-28",
Opts: &SearchOptions{
UpdatedAfterUnix: optional.Some(int64(1756339200)),
UpdatedBeforeUnix: optional.Some(int64(1756339200)),
},
},
{
Keyword: "assignee:test",
Opts: &SearchOptions{
AssigneeID: optional.Some(int64(2)),
},
},
{
Keyword: "assignee:test hi",
Opts: &SearchOptions{
AssigneeID: optional.Some(int64(2)),
Tokens: []Token{
{
Term: "hi",
Kind: BoolOptShould,
Fuzzy: true,
},
},
},
},
{
Keyword: "mentions:test",
Opts: &SearchOptions{
MentionID: optional.Some(int64(2)),
},
},
{
Keyword: "review:test",
Opts: &SearchOptions{
ReviewedID: optional.Some(int64(2)),
},
},
{
Keyword: "author:test",
Opts: &SearchOptions{
PosterID: optional.Some(int64(2)),
},
},
{
Keyword: "sort:updated:asc",
Opts: &SearchOptions{
SortBy: SortByUpdatedAsc,
},
},
{
Keyword: "sort:test",
Opts: &SearchOptions{
SortBy: SortByScore,
},
},
{
Keyword: "test author:test mentions:test modified:<2025-08-28 sort:comments:desc",
Opts: &SearchOptions{
Tokens: []Token{
{
Term: "test",
Kind: BoolOptShould,
Fuzzy: true,
},
},
MentionID: optional.Some(int64(2)),
PosterID: optional.Some(int64(2)),
UpdatedBeforeUnix: optional.Some(int64(1756339200)),
SortBy: SortByCommentsDesc,
},
},
// Edge Cases
{
Keyword: "author:",
Opts: &SearchOptions{
Tokens: []Token{
{
Term: "author:",
Kind: BoolOptShould,
Fuzzy: true,
},
},
},
},
{
Keyword: "author:testt",
Opts: &SearchOptions{},
},
{
Keyword: "author: test",
Opts: &SearchOptions{
Tokens: []Token{
{
Term: "author:",
Kind: BoolOptShould,
Fuzzy: true,
},
{
Term: "test",
Kind: BoolOptShould,
Fuzzy: true,
},
},
},
},
{
Keyword: "modified:",
Opts: &SearchOptions{
Tokens: []Token{
{
Term: "modified:",
Kind: BoolOptShould,
Fuzzy: true,
},
},
},
},
} {
t.Run(c.Keyword, func(t *testing.T) {
opts := &SearchOptions{}
require.NoError(t, opts.WithKeyword(context.Background(), c.Keyword))
assert.Equal(t, c.Opts, opts)
})
}
}

View file

@ -63,6 +63,7 @@ func TestIndexer(t *testing.T, indexer internal.Indexer) {
}()
}
require.NoError(t, c.SearchOptions.WithKeyword(t.Context(), c.Keyword))
result, err := indexer.Search(t.Context(), c.SearchOptions)
require.NoError(t, err)
@ -99,38 +100,34 @@ var cases = []*testIndexerCase{
Expected: allResults,
},
{
Name: "empty keyword",
SearchOptions: &internal.SearchOptions{
Keyword: "",
},
Expected: allResults,
Name: "empty keyword",
Keyword: "",
SearchOptions: &internal.SearchOptions{},
Expected: allResults,
},
{
Name: "whitespace keyword",
SearchOptions: &internal.SearchOptions{
Keyword: " ",
},
Expected: allResults,
Name: "whitespace keyword",
Keyword: " ",
SearchOptions: &internal.SearchOptions{},
Expected: allResults,
},
{
Name: "dangling slash in keyword",
SearchOptions: &internal.SearchOptions{
Keyword: "\\",
},
Expected: allResults,
Name: "dangling slash in keyword",
Keyword: "\\",
SearchOptions: &internal.SearchOptions{},
Expected: allResults,
},
{
Name: "dangling quote in keyword",
SearchOptions: &internal.SearchOptions{
Keyword: "\"",
},
Expected: allResults,
Name: "dangling quote in keyword",
Keyword: "\"",
SearchOptions: &internal.SearchOptions{},
Expected: allResults,
},
{
Name: "empty",
SearchOptions: &internal.SearchOptions{
Keyword: "f1dfac73-fda6-4a6b-b8a4-2408fcb8ef69",
},
Name: "empty",
Keyword: "f1dfac73-fda6-4a6b-b8a4-2408fcb8ef69",
SearchOptions: &internal.SearchOptions{},
ExpectedIDs: []int64{},
ExpectedTotal: 0,
},
@ -153,9 +150,9 @@ var cases = []*testIndexerCase{
{ID: 1001, Content: "hi hello world"},
{ID: 1002, Comments: []string{"hi", "hello world"}},
},
Keyword: "hello",
SearchOptions: &internal.SearchOptions{
Keyword: "hello",
SortBy: internal.SortByCreatedDesc,
SortBy: internal.SortByCreatedDesc,
},
ExpectedIDs: []int64{1002, 1001, 1000},
ExpectedTotal: 3,
@ -167,9 +164,9 @@ var cases = []*testIndexerCase{
{ID: 1001, Content: "hi hello world"},
{ID: 1002, Comments: []string{"hello", "hello world"}},
},
Keyword: "hello world -hi",
SearchOptions: &internal.SearchOptions{
Keyword: "hello world -hi",
SortBy: internal.SortByCreatedDesc,
SortBy: internal.SortByCreatedDesc,
},
ExpectedIDs: []int64{1002},
ExpectedTotal: 1,
@ -181,9 +178,9 @@ var cases = []*testIndexerCase{
{ID: 1001, Content: "hi hello world"},
{ID: 1002, Comments: []string{"hi", "hello world"}},
},
Keyword: "hello world",
SearchOptions: &internal.SearchOptions{
Keyword: "hello world",
SortBy: internal.SortByCreatedDesc,
SortBy: internal.SortByCreatedDesc,
},
ExpectedIDs: []int64{1002, 1001, 1000},
ExpectedTotal: 3,
@ -199,8 +196,8 @@ var cases = []*testIndexerCase{
{ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false},
{ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false},
},
Keyword: "hello",
SearchOptions: &internal.SearchOptions{
Keyword: "hello",
SortBy: internal.SortByCreatedDesc,
RepoIDs: []int64{1, 4},
},
@ -218,8 +215,8 @@ var cases = []*testIndexerCase{
{ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false},
{ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false},
},
Keyword: "hello",
SearchOptions: &internal.SearchOptions{
Keyword: "hello",
SortBy: internal.SortByCreatedDesc,
RepoIDs: []int64{1, 4},
AllPublic: true,
@ -300,8 +297,9 @@ var cases = []*testIndexerCase{
{ID: 1003, Title: "hello d", LabelIDs: []int64{2000}},
{ID: 1004, Title: "hello e", LabelIDs: []int64{}},
},
Keyword: "hello",
SearchOptions: &internal.SearchOptions{
Keyword: "hello",
IncludedLabelIDs: []int64{2000, 2001},
ExcludedLabelIDs: []int64{2003},
},
@ -317,8 +315,9 @@ var cases = []*testIndexerCase{
{ID: 1003, Title: "hello d", LabelIDs: []int64{2002}},
{ID: 1004, Title: "hello e", LabelIDs: []int64{}},
},
Keyword: "hello",
SearchOptions: &internal.SearchOptions{
Keyword: "hello",
IncludedAnyLabelIDs: []int64{2001, 2002},
ExcludedLabelIDs: []int64{2003},
},
@ -580,9 +579,9 @@ var cases = []*testIndexerCase{
},
},
{
Name: "Index",
Name: "Index",
Keyword: "13",
SearchOptions: &internal.SearchOptions{
Keyword: "13",
SortBy: internal.SortByScore,
RepoIDs: []int64{5},
},
@ -590,9 +589,10 @@ var cases = []*testIndexerCase{
ExpectedTotal: 1,
},
{
Name: "Index with prefix",
Name: "Index with prefix",
Keyword: "#13",
SearchOptions: &internal.SearchOptions{
Keyword: "#13",
SortBy: internal.SortByScore,
RepoIDs: []int64{5},
},
@ -605,8 +605,9 @@ var cases = []*testIndexerCase{
{ID: 1001, Title: "re #13", RepoID: 5},
{ID: 1002, Title: "re #1001", Content: "leave 13 alone. - 13", RepoID: 5},
},
Keyword: "!13",
SearchOptions: &internal.SearchOptions{
Keyword: "!13",
SortBy: internal.SortByScore,
RepoIDs: []int64{5},
},
@ -621,9 +622,10 @@ var cases = []*testIndexerCase{
{ID: 1003, Index: 103, Title: "Brrr", RepoID: 5},
{ID: 1004, Index: 104, Title: "Brrr", RepoID: 5},
},
Keyword: "Brrr -101 -103",
SearchOptions: &internal.SearchOptions{
Keyword: "Brrr -101 -103",
SortBy: internal.SortByScore,
SortBy: internal.SortByScore,
},
ExpectedIDs: []int64{1002, 1004},
ExpectedTotal: 2,
@ -797,6 +799,7 @@ type testIndexerCase struct {
Name string
ExtraData []*internal.IndexerData
Keyword string
SearchOptions *internal.SearchOptions
Expected func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) // if nil, use ExpectedIDs, ExpectedTotal

View file

@ -233,12 +233,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
}
var keywords []string
if options.Keyword != "" {
tokens, err := options.Tokens()
if err != nil {
return nil, err
}
for _, token := range tokens {
if len(options.Tokens) != 0 {
for _, token := range options.Tokens {
if !token.Fuzzy {
// to make it a phrase search, we have to quote the keyword(s)
// https://www.meilisearch.com/docs/reference/api/search#phrase-search