diff --git a/packages/sanity/src/core/search/groq2024/createGroq2024Search.ts b/packages/sanity/src/core/search/groq2024/createGroq2024Search.ts index 204bf405d20..3af52a5aeb3 100644 --- a/packages/sanity/src/core/search/groq2024/createGroq2024Search.ts +++ b/packages/sanity/src/core/search/groq2024/createGroq2024Search.ts @@ -3,8 +3,10 @@ import {map} from 'rxjs' import { type Groq2024SearchResults, + type SearchSort, type SearchStrategyFactory, type SearchTerms, + type SortDirection, } from '../common/types' import {createSearchQuery} from './createSearchQuery' @@ -32,10 +34,16 @@ export const createGroq2024Search: SearchStrategyFactory return function search(searchParams, searchOptions = {}) { const searchTerms = getSearchTerms(searchParams, typesFromFactory) - const {query, params, options} = createSearchQuery(searchTerms, searchParams, { + const mergedOptions = { ...factoryOptions, ...searchOptions, - }) + } + + const {query, params, options, sortOrder} = createSearchQuery( + searchTerms, + searchParams, + mergedOptions, + ) return client.observable .withConfig({ @@ -53,19 +61,56 @@ export const createGroq2024Search: SearchStrategyFactory // the penultimate result must be used to determine the start of the next page. const lastResult = hasNextPage ? hits.at(-2) : hits.at(-1) - // TODO: This cursor must change if not sorting by `_score`. - const nextCursor = hasNextPage - ? `(_score < ${lastResult?._score} || (_score == ${lastResult?._score} && _id > "${lastResult?._id}"))` - : undefined - return { type: 'groq2024', // Search overfetches by 1 to determine whether there is another page to fetch. Therefore, // exclude the final result if it's beyond the limit. hits: hits.map((hit) => ({hit})).slice(0, searchOptions.limit), - nextCursor, + nextCursor: hasNextPage ? getNextCursor({lastResult, sortOrder}) : undefined, } }), ) } } + +function getNextCursor({ + lastResult, + sortOrder, +}: { + lastResult?: SanityDocumentLike + sortOrder: SearchSort[] +}): string | undefined { + if (!lastResult) { + return undefined + } + + return ( + sortOrder + // Content Lake always orders by `_id asc` as a tiebreaker. + .concat({field: '_id', direction: 'asc'}) + .reduce((cursor, sortEntry, index) => { + const nextPredicate = sortOrder + .slice(0, index) + .map((previousSortEntry) => getCursorPredicate(previousSortEntry, lastResult, '==')) + .concat(getCursorPredicate(sortEntry, lastResult)) + .join(' && ') + + return [cursor, `(${nextPredicate})`] + .filter((segment) => typeof segment !== 'undefined') + .join(' || ') + }, undefined) + ) +} + +const sortComparators: Record' | '<'> = { + asc: '>', + desc: '<', +} + +function getCursorPredicate( + sort: SearchSort, + lastEntry: SanityDocumentLike, + comparator: '>' | '<' | '==' = sortComparators[sort.direction], +): string { + return [sort.field, comparator, JSON.stringify(lastEntry[sort.field])].join(' ') +} diff --git a/packages/sanity/src/core/search/groq2024/createSearchQuery.ts b/packages/sanity/src/core/search/groq2024/createSearchQuery.ts index 1581a9fabc1..47a72fafe95 100644 --- a/packages/sanity/src/core/search/groq2024/createSearchQuery.ts +++ b/packages/sanity/src/core/search/groq2024/createSearchQuery.ts @@ -23,6 +23,7 @@ interface SearchQuery { query: string params: SearchParams options: Record + sortOrder: SearchSort[] } function isSchemaType( @@ -65,16 +66,19 @@ export function createSearchQuery( ) .filter(({paths}) => paths.length !== 0) + // TODO: Unnecessary when `!isScored`. const flattenedSpecs = specs .map(({typeName, paths}) => paths.map((path) => ({...path, typeName}))) .flat() .filter(({weight}) => weight !== 0) + // TODO: Unnecessary when `!isScored`. const groupedSpecs = groupBy( flattenedSpecs, (entry) => `text::matchQuery(${entry.path}, $__query), ${entry.weight}`, ) + // TODO: Unnecessary when `!isScored`. const score = Object.entries(groupedSpecs) .map(([args, entries]) => { if (entries.length === 1) { @@ -84,23 +88,28 @@ export function createSearchQuery( }) .concat([`text::matchQuery(@, $__query)`]) + const sortOrder = options?.sort ?? [{field: '_score', direction: 'desc'}] + const isScored = sortOrder.some(({field}) => field === '_score') + const filters = [ '_type in $__types', + // TODO: It will be necessary to omit zero-weighted paths when `!isScored`. + isScored ? false : `text::matchQuery(@, $__query)`, options.filter ? `(${options.filter})` : false, searchTerms.filter ? `(${searchTerms.filter})` : false, '!(_id in path("versions.**"))', options.cursor, ].filter((baseFilter) => typeof baseFilter === 'string') - const sortOrder = toOrderClause(options?.sort ?? [{field: '_score', direction: 'desc'}]) - const projectionFields = ['_type', '_id', '_score'] + // TODO: Any field used in `sortOrder` must be projected in order to create a cursor. + const projectionFields = ['_type', '_id', ...(isScored ? ['_score'] : [])] const projection = projectionFields.join(', ') const query = [ `*[${filters.join(' && ')}]`, - ['|', `score(${score.join(', ')})`], - ['|', `order(${sortOrder})`], - `[_score > 0]`, + isScored ? ['|', `score(${score.join(', ')})`] : [], + ['|', `order(${toOrderClause(sortOrder)})`], + isScored ? `[_score > 0]` : [], `[0...$__limit]`, `{${projection}}`, ] @@ -129,5 +138,6 @@ export function createSearchQuery( query: [pragma, query].join('\n'), options: pick(options, ['tag']), params, + sortOrder, } }