mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
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:
parent
4d0c7db6cd
commit
255ed593d3
26 changed files with 870 additions and 296 deletions
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue