Files
yay/pkg/query/query_builder.go
T
Joey Holtzman 733e6a6b17 Add support for all sortyby options (#2747)
* Add all sortyby options

Previously, only 'name' was a supported sortby option. Now, all sortby
options are supported.

Whenever there is a tie, the metric is used as a tiebreaker (it is also
the default when sortby is not specified). The only behavior that
changed is that packages with the same metric are sorted oppositely when
sorting bottomUp. This was actually a bug in the previous implementation
which inverts the `cmpResult` for bottomUp which returns true if the
metrics are the same. According to the docs for Less
(https://pkg.go.dev/sort#Interface), Less(i, i) should be false (the
same element cannot sort before itself).

I mainly structured the code for efficiency by creating the sortby
function once rather than checking `separateSources` and `bottomUp`
every comparison, but I also think this improves readability compared to
having a switch-case in the comparison function.

* Fix separating sources on ties and improve test coverage

* Remove 'id' and 'baseid' in sortby options
2026-01-22 09:26:35 +01:00

358 lines
8.8 KiB
Go

package query
import (
"cmp"
"context"
"sort"
"strconv"
"strings"
"unicode"
"github.com/Jguer/aur"
"github.com/Jguer/go-alpm/v2"
"github.com/adrg/strutil"
"github.com/adrg/strutil/metrics"
mapset "github.com/deckarep/golang-set/v2"
"github.com/leonelquinteros/gotext"
"github.com/Jguer/yay/v12/pkg/db"
"github.com/Jguer/yay/v12/pkg/intrange"
"github.com/Jguer/yay/v12/pkg/settings/parser"
"github.com/Jguer/yay/v12/pkg/text"
)
const sourceAUR = "AUR"
type SearchVerbosity int
// Verbosity settings for search.
const (
NumberMenu SearchVerbosity = iota
Detailed
Minimal
)
type Builder interface {
Len() int
Execute(ctx context.Context, dbExecutor db.Executor, pkgS []string)
Results(dbExecutor db.Executor, verboseSearch SearchVerbosity) error
GetTargets(include, exclude intrange.IntRanges, otherExclude mapset.Set[string]) ([]string, error)
}
type SortFunc func(pkgA, pkgB abstractResult) int
type SourceQueryBuilder struct {
results []abstractResult
sortBy string
searchBy string
targetMode parser.TargetMode
queryMap map[string]map[string]any
bottomUp bool
singleLineResults bool
separateSources bool
aurClient aur.QueryClient
logger *text.Logger
}
func NewSourceQueryBuilder(
aurClient aur.QueryClient,
logger *text.Logger,
sortBy string,
targetMode parser.TargetMode,
searchBy string,
bottomUp,
singleLineResults bool,
separateSources bool,
) *SourceQueryBuilder {
return &SourceQueryBuilder{
aurClient: aurClient,
logger: logger,
bottomUp: bottomUp,
sortBy: sortBy,
targetMode: targetMode,
searchBy: searchBy,
singleLineResults: singleLineResults,
separateSources: separateSources,
queryMap: map[string]map[string]any{},
results: make([]abstractResult, 0, 100),
}
}
type abstractResult struct {
source string
name string
description string
packageBase string
votes int
popularity float64
firstSubmitted int
lastModified int
provides []string
}
type abstractResults struct {
results []abstractResult
search string
metric strutil.StringMetric
separateSources bool
sortByFunc SortFunc
repoOrder []string
distanceCache map[string]float64
separateSourceCache map[string]float64
}
func (a *abstractResults) Len() int { return len(a.results) }
func (a *abstractResults) Swap(i, j int) { a.results[i], a.results[j] = a.results[j], a.results[i] }
func (a *abstractResults) Less(i, j int) bool {
pkgA := a.results[i]
pkgB := a.results[j]
// Sort in descending order by default
return a.sortByFunc(pkgA, pkgB) > 0
}
func (a *abstractResults) GetSortFunc(sortBy string, bottomUp bool) SortFunc {
var sortFunc SortFunc
// Primary sort
switch sortBy {
case "base":
sortFunc = func(pkgA, pkgB abstractResult) int {
return cmp.Compare(pkgA.packageBase, pkgB.packageBase)
}
case "modified":
sortFunc = func(pkgA, pkgB abstractResult) int {
return cmp.Compare(pkgA.lastModified, pkgB.lastModified)
}
case "name":
sortFunc = func(pkgA, pkgB abstractResult) int {
return cmp.Compare(pkgA.name, pkgB.name)
}
case "popularity":
sortFunc = func(pkgA, pkgB abstractResult) int {
return cmp.Compare(pkgA.popularity, pkgB.popularity)
}
case "submitted":
sortFunc = func(pkgA, pkgB abstractResult) int {
return cmp.Compare(pkgA.firstSubmitted, pkgB.firstSubmitted)
}
case "votes":
sortFunc = func(pkgA, pkgB abstractResult) int {
return cmp.Compare(pkgA.votes, pkgB.votes)
}
default:
sortFunc = func(pkgA, pkgB abstractResult) int {
return 0
}
}
// Sort by metric as a tie-breaker. Also handle separating sources when not a tie
{
originalSortFunc := sortFunc
sortFunc = func(pkgA, pkgB abstractResult) int {
if cmpResult := originalSortFunc(pkgA, pkgB); cmpResult != 0 {
if a.separateSources {
if cmpSources := strings.Compare(pkgA.source, pkgB.source); cmpSources != 0 {
return cmpSources
}
}
return cmpResult
}
metricA := a.calculateMetric(&pkgA)
metricB := a.calculateMetric(&pkgB)
return cmp.Compare(metricA, metricB)
}
}
if bottomUp {
// Invert sort for bottom-up sorting
originalSortFunc := sortFunc
sortFunc = func(pkgA, pkgB abstractResult) int {
return -originalSortFunc(pkgA, pkgB)
}
}
return sortFunc
}
func (s *SourceQueryBuilder) Execute(ctx context.Context, dbExecutor db.Executor, pkgS []string) {
var aurErr error
pkgS = RemoveInvalidTargets(s.logger, pkgS, s.targetMode)
metric := &metrics.Hamming{
CaseSensitive: false,
}
sortableResults := &abstractResults{
results: []abstractResult{},
search: strings.Join(pkgS, ""),
metric: metric,
separateSources: s.separateSources,
repoOrder: dbExecutor.Repos(),
distanceCache: map[string]float64{},
separateSourceCache: map[string]float64{},
}
sortableResults.sortByFunc = sortableResults.GetSortFunc(s.sortBy, s.bottomUp)
var repoResults []alpm.IPackage
if s.targetMode.AtLeastRepo() {
repoResults = dbExecutor.SyncPackages(pkgS...)
for i := range repoResults {
dbName := repoResults[i].DB().Name()
if s.queryMap[dbName] == nil {
s.queryMap[dbName] = map[string]any{}
}
s.queryMap[dbName][repoResults[i].Name()] = repoResults[i]
rawProvides := repoResults[i].Provides().Slice()
provides := make([]string, len(rawProvides))
for j := range rawProvides {
provides[j] = rawProvides[j].Name
}
sortableResults.results = append(sortableResults.results, abstractResult{
source: repoResults[i].DB().Name(),
name: repoResults[i].Name(),
description: repoResults[i].Description(),
packageBase: repoResults[i].Base(),
votes: -1,
popularity: -1,
firstSubmitted: -1,
lastModified: -1,
provides: provides,
})
}
}
if s.targetMode.AtLeastAUR() {
var aurResults []aur.Pkg
aurResults, aurErr = queryAUR(ctx, s.aurClient, pkgS, s.searchBy)
dbName := sourceAUR
for i := range aurResults {
if s.queryMap[dbName] == nil {
s.queryMap[dbName] = map[string]any{}
}
by := getSearchBy(s.searchBy)
if (by == aur.NameDesc || by == aur.None || by == aur.Name) &&
!matchesSearch(&aurResults[i], pkgS) {
continue
}
s.queryMap[dbName][aurResults[i].Name] = aurResults[i]
sortableResults.results = append(sortableResults.results, abstractResult{
source: dbName,
name: aurResults[i].Name,
description: aurResults[i].Description,
packageBase: aurResults[i].PackageBase,
votes: aurResults[i].NumVotes,
popularity: aurResults[i].Popularity,
firstSubmitted: aurResults[i].FirstSubmitted,
lastModified: aurResults[i].LastModified,
provides: aurResults[i].Provides,
})
}
}
sort.Sort(sortableResults)
s.results = sortableResults.results
if aurErr != nil {
s.logger.Errorln(ErrAURSearch{inner: aurErr})
if len(repoResults) != 0 {
s.logger.Warnln(gotext.Get("Showing repo packages only"))
}
}
}
func (s *SourceQueryBuilder) Results(dbExecutor db.Executor, verboseSearch SearchVerbosity) error {
for i := range s.results {
if verboseSearch == Minimal {
s.logger.Println(s.results[i].name)
continue
}
var toPrint string
if verboseSearch == NumberMenu {
if s.bottomUp {
toPrint += text.Magenta(strconv.Itoa(len(s.results)-i)) + " "
} else {
toPrint += text.Magenta(strconv.Itoa(i+1)) + " "
}
}
pkg := s.queryMap[s.results[i].source][s.results[i].name]
switch pPkg := pkg.(type) {
case aur.Pkg:
toPrint += aurPkgSearchString(&pPkg, dbExecutor, s.singleLineResults)
case alpm.IPackage:
toPrint += syncPkgSearchString(pPkg, dbExecutor, s.singleLineResults)
}
s.logger.Println(toPrint)
}
return nil
}
func (s *SourceQueryBuilder) Len() int {
return len(s.results)
}
func (s *SourceQueryBuilder) GetTargets(include, exclude intrange.IntRanges,
otherExclude mapset.Set[string],
) ([]string, error) {
var (
isInclude = len(exclude) == 0 && otherExclude.Cardinality() == 0
targets []string
lenRes = len(s.results)
)
for i := 1; i <= s.Len(); i++ {
target := i - 1
if s.bottomUp {
target = lenRes - i
}
if (isInclude && include.Get(i)) || (!isInclude && !exclude.Get(i)) {
targets = append(targets, s.results[target].source+"/"+s.results[target].name)
}
}
return targets, nil
}
func matchesSearch(pkg *aur.Pkg, terms []string) bool {
if len(terms) <= 1 {
return true
}
for _, pkgN := range terms {
if strings.IndexFunc(pkgN, unicode.IsSymbol) != -1 {
return true
}
name := strings.ToLower(pkg.Name)
desc := strings.ToLower(pkg.Description)
targ := strings.ToLower(pkgN)
if !strings.Contains(name, targ) && !strings.Contains(desc, targ) {
return false
}
}
return true
}