Compare commits
7 Commits
main
...
fix-parade
| Author | SHA1 | Date |
|---|---|---|
|
|
a2cb2826d0 | |
|
|
6f6f91bd28 | |
|
|
cefa42da86 | |
|
|
78dde2fb18 | |
|
|
d93e98f76b | |
|
|
116fb1e2e0 | |
|
|
9fb0d86c1b |
|
|
@ -0,0 +1,83 @@
|
||||||
|
import {describe, it, expect, beforeEach, vi} from 'vitest'
|
||||||
|
import {defineComponent, h, nextTick} from 'vue'
|
||||||
|
import {mount, flushPromises} from '@vue/test-utils'
|
||||||
|
import {setActivePinia, createPinia} from 'pinia'
|
||||||
|
import {createRouter, createMemoryHistory, type Router} from 'vue-router'
|
||||||
|
|
||||||
|
const getAll = vi.fn(async () => [])
|
||||||
|
vi.mock('@/services/taskCollection', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/services/taskCollection')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: class {
|
||||||
|
loading = false
|
||||||
|
totalPages = 1
|
||||||
|
getAll = getAll
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import {useTaskList} from './useTaskList'
|
||||||
|
|
||||||
|
// The second positional argument passed to TaskCollectionService.getAll carries
|
||||||
|
// the sort_by/order_by the backend uses to decide whether to rank by relevance.
|
||||||
|
function lastRequestParams(): Record<string, unknown> {
|
||||||
|
return getAll.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountTaskList(query: Record<string, string>): Promise<Router> {
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [{path: '/', name: 'home', component: {render: () => null}}],
|
||||||
|
})
|
||||||
|
await router.push({path: '/', query})
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const TestComponent = defineComponent({
|
||||||
|
setup() {
|
||||||
|
useTaskList(() => 1, () => 1)
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
mount(TestComponent, {global: {plugins: [router]}})
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useTaskList sort handling for relevance ranking', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
getAll.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits the sort while searching with the default sort so the backend ranks by relevance', async () => {
|
||||||
|
await mountTaskList({s: 'find me'})
|
||||||
|
|
||||||
|
const params = lastRequestParams()
|
||||||
|
expect(params.s).toBe('find me')
|
||||||
|
expect(params.sort_by).toEqual([])
|
||||||
|
expect(params.order_by).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an explicit user sort while searching so the user sort is respected', async () => {
|
||||||
|
await mountTaskList({s: 'find me', sort: 'title:asc'})
|
||||||
|
|
||||||
|
const params = lastRequestParams()
|
||||||
|
expect(params.s).toBe('find me')
|
||||||
|
expect(params.sort_by).toEqual(['title'])
|
||||||
|
expect(params.order_by).toEqual(['asc'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the default sort when not searching', async () => {
|
||||||
|
await mountTaskList({})
|
||||||
|
|
||||||
|
const params = lastRequestParams()
|
||||||
|
expect(params.s).toBe('')
|
||||||
|
expect(params.sort_by).not.toHaveLength(0)
|
||||||
|
// id always sorts last so other sort columns take precedence.
|
||||||
|
expect(params.sort_by).toEqual(['id'])
|
||||||
|
expect(params.order_by).toEqual(['desc'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -122,6 +122,14 @@ export function useTaskList(
|
||||||
const allParams = computed(() => {
|
const allParams = computed(() => {
|
||||||
const loadParams = {...params.value}
|
const loadParams = {...params.value}
|
||||||
|
|
||||||
|
// Relevance ranking only engages when no sort is sent, so omit the default
|
||||||
|
// sort while searching and let an explicit user sort still take precedence.
|
||||||
|
if (loadParams.s && !sortQuery.value) {
|
||||||
|
loadParams.sort_by = []
|
||||||
|
loadParams.order_by = []
|
||||||
|
return loadParams
|
||||||
|
}
|
||||||
|
|
||||||
return formatSortOrder(sortBy.value, loadParams)
|
return formatSortOrder(sortBy.value, loadParams)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection, projectView *ProjectVie
|
||||||
|
|
||||||
opts = &taskSearchOptions{
|
opts = &taskSearchOptions{
|
||||||
sortby: sort,
|
sortby: sort,
|
||||||
|
userProvidedSort: len(tf.SortBy) > 0,
|
||||||
filterIncludeNulls: tf.FilterIncludeNulls,
|
filterIncludeNulls: tf.FilterIncludeNulls,
|
||||||
filter: tf.Filter,
|
filter: tf.Filter,
|
||||||
filterTimezone: tf.FilterTimezone,
|
filterTimezone: tf.FilterTimezone,
|
||||||
|
|
|
||||||
|
|
@ -321,12 +321,12 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
|
||||||
// Then return all tasks for that projects
|
// Then return all tasks for that projects
|
||||||
var where builder.Cond
|
var where builder.Cond
|
||||||
|
|
||||||
|
searchIndex := getTaskIndexFromSearchString(opts.search)
|
||||||
if opts.search != "" {
|
if opts.search != "" {
|
||||||
where = db.MultiFieldSearchWithTableAlias([]string{"title", "description"}, opts.search, "tasks")
|
where = db.MultiFieldSearchWithTableAlias([]string{"title", "description"}, opts.search, "tasks")
|
||||||
|
|
||||||
searchIndex := getTaskIndexFromSearchString(opts.search)
|
|
||||||
if searchIndex > 0 {
|
if searchIndex > 0 {
|
||||||
where = builder.Or(where, builder.Eq{"`index`": searchIndex})
|
where = builder.Or(where, builder.Eq{"tasks.`index`": searchIndex})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,9 +374,32 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
query := d.s.
|
// ParadeDB exposes the BM25 relevance score via pdb.score(tasks.id) for a query
|
||||||
Distinct(distinct).
|
// containing a ParadeDB operator (the ||| from MultiFieldSearch qualifies). When
|
||||||
Where(cond)
|
// searching without an explicit user sort, order by relevance so tasks matching
|
||||||
|
// all query words rank above tasks matching only some.
|
||||||
|
//
|
||||||
|
// This is limited to pure-text searches over a plain project scope: numeric
|
||||||
|
// searches add an `OR index = N` branch and the Favorites view scopes on an
|
||||||
|
// `id IN (<subquery>)`, both of which pdb.score rejects as unsupported query
|
||||||
|
// shapes. Those keep the default ordering (unranked). pdb.score is also invalid
|
||||||
|
// SQL on sqlite/mysql/plain postgres, hence the ParadeDBAvailable() gate.
|
||||||
|
rankByRelevance := db.ParadeDBAvailable() &&
|
||||||
|
opts.search != "" &&
|
||||||
|
!opts.userProvidedSort &&
|
||||||
|
searchIndex == 0 &&
|
||||||
|
!d.hasFavoritesProject
|
||||||
|
|
||||||
|
query := d.s.Where(cond)
|
||||||
|
if rankByRelevance {
|
||||||
|
// Select() passes the raw column list through untouched while Distinct()
|
||||||
|
// (no args) still emits DISTINCT. Distinct("tasks.*, pdb.score(tasks.id)")
|
||||||
|
// would quote-corrupt the function call into "pdb"."score(tasks"."id)".
|
||||||
|
query = query.Select(distinct + ", pdb.score(tasks.id)").Distinct()
|
||||||
|
orderby = "pdb.score(tasks.id) DESC, " + orderby
|
||||||
|
} else {
|
||||||
|
query = query.Distinct(distinct)
|
||||||
|
}
|
||||||
if limit > 0 {
|
if limit > 0 {
|
||||||
query = query.Limit(limit, start)
|
query = query.Limit(limit, start)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,3 +54,91 @@ func TestKanbanViewBucketFiltering(t *testing.T) {
|
||||||
assert.NotContains(t, taskBuckets, id)
|
assert.NotContains(t, taskBuckets, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTaskSearchRelevanceRanking verifies that a multi-word search ranks the task
|
||||||
|
// matching all words above tasks matching only some. The ranking is BM25-based and
|
||||||
|
// therefore only enforced on ParadeDB; on other databases we only assert that the
|
||||||
|
// matching tasks are returned (no order guarantee), keeping the test green across
|
||||||
|
// the whole CI database matrix.
|
||||||
|
func TestTaskSearchRelevanceRanking(t *testing.T) {
|
||||||
|
db.LoadAndAssertFixtures(t)
|
||||||
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
usr := &user.User{ID: 1}
|
||||||
|
|
||||||
|
allWords := &Task{Title: "Backup server migration", ProjectID: 1}
|
||||||
|
require.NoError(t, allWords.Create(s, usr))
|
||||||
|
oneWordA := &Task{Title: "Backup of old files", ProjectID: 1}
|
||||||
|
require.NoError(t, oneWordA.Create(s, usr))
|
||||||
|
oneWordB := &Task{Title: "server room booking", ProjectID: 1}
|
||||||
|
require.NoError(t, oneWordB.Create(s, usr))
|
||||||
|
|
||||||
|
assertRelevanceRanked := func(t *testing.T, tc *TaskCollection) {
|
||||||
|
got, _, _, err := tc.ReadAll(s, usr, "backup server", 0, 50)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
gotTasks, is := got.([]*Task)
|
||||||
|
require.True(t, is)
|
||||||
|
|
||||||
|
gotIDs := make([]int64, len(gotTasks))
|
||||||
|
for i, tsk := range gotTasks {
|
||||||
|
gotIDs[i] = tsk.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Contains(t, gotIDs, allWords.ID, "the task matching all words should be returned")
|
||||||
|
|
||||||
|
if db.ParadeDBAvailable() {
|
||||||
|
require.NotEmpty(t, gotTasks)
|
||||||
|
assert.Equal(t, allWords.ID, gotTasks[0].ID, "task matching all query words should rank first by BM25 relevance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a view: plain "tasks.*, pdb.score(tasks.id)" select.
|
||||||
|
t.Run("no view", func(t *testing.T) {
|
||||||
|
assertRelevanceRanked(t, &TaskCollection{ProjectID: 1})
|
||||||
|
})
|
||||||
|
|
||||||
|
// With a view: exercises the task_positions LEFT JOIN, which adds
|
||||||
|
// task_positions.position to the DISTINCT select alongside pdb.score(tasks.id).
|
||||||
|
t.Run("list view", func(t *testing.T) {
|
||||||
|
assertRelevanceRanked(t, &TaskCollection{ProjectID: 1, ProjectViewID: 1})
|
||||||
|
})
|
||||||
|
|
||||||
|
// An explicit sort_by must win over relevance: with `id desc` the lowest-id
|
||||||
|
// task (allWords) ranks last, the opposite of what BM25 relevance would do.
|
||||||
|
// This locks the contract that user-provided sorting disables relevance
|
||||||
|
// ranking even on ParadeDB. Only ParadeDB's per-token search matches all
|
||||||
|
// three tasks, so the ordering contract is only asserted there (other
|
||||||
|
// databases ILIKE the whole phrase and match a different subset).
|
||||||
|
t.Run("explicit sort disables relevance ranking", func(t *testing.T) {
|
||||||
|
if !db.ParadeDBAvailable() {
|
||||||
|
t.Skip("relevance ranking only applies on ParadeDB")
|
||||||
|
}
|
||||||
|
|
||||||
|
tc := &TaskCollection{
|
||||||
|
ProjectID: 1,
|
||||||
|
SortBy: []string{"id"},
|
||||||
|
OrderBy: []string{"desc"},
|
||||||
|
}
|
||||||
|
got, _, _, err := tc.ReadAll(s, usr, "backup server", 0, 50)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
gotTasks, is := got.([]*Task)
|
||||||
|
require.True(t, is)
|
||||||
|
|
||||||
|
created := map[int64]bool{allWords.ID: true, oneWordA.ID: true, oneWordB.ID: true}
|
||||||
|
var orderedIDs []int64
|
||||||
|
for _, tsk := range gotTasks {
|
||||||
|
if created[tsk.ID] {
|
||||||
|
orderedIDs = append(orderedIDs, tsk.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(t, orderedIDs, len(created), "all created tasks should match the search")
|
||||||
|
for i := 1; i < len(orderedIDs); i++ {
|
||||||
|
assert.Greater(t, orderedIDs[i-1], orderedIDs[i], "tasks must follow the explicit id-desc sort, not relevance")
|
||||||
|
}
|
||||||
|
assert.Equal(t, allWords.ID, orderedIDs[len(orderedIDs)-1], "the all-words match (lowest id) ranks last under id-desc, proving relevance was not applied")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,10 @@ type taskSearchOptions struct {
|
||||||
projectIDs []int64
|
projectIDs []int64
|
||||||
expand []TaskCollectionExpandable
|
expand []TaskCollectionExpandable
|
||||||
projectViewID int64
|
projectViewID int64
|
||||||
|
|
||||||
|
// userProvidedSort distinguishes an explicit sort_by from the id/position
|
||||||
|
// defaults appended later, so relevance ordering only replaces the default sort.
|
||||||
|
userProvidedSort bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadAll is a dummy function to still have that endpoint documented
|
// ReadAll is a dummy function to still have that endpoint documented
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue