Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(sanity): allow discovery of all document versions using groq2024 search #8775

Merged
merged 3 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ function getSearchTerms(
}

/**
* Note: When using the `raw` persepctive, `groq2024` may emit uncollated documents, manifesting as
* duplicate search results. Consumers must collate the results.
*
* @internal
*/
export const createGroq2024Search: SearchStrategyFactory<Groq2024SearchResults> = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('createSearchQuery', () => {
expect(query).toMatchInlineSnapshot(
`
"// findability-mvi:5
*[_type in $__types && !(_id in path("versions.**"))] | score(boost(_type in ["basic-schema-test"] && title match text::query($__query), 10), [@, _id] match text::query($__query)) | order(_score desc) [_score > 0] [0...$__limit] {_score, _type, _id, _originalId}"
*[_type in $__types] | score(boost(_type in ["basic-schema-test"] && title match text::query($__query), 10), [@, _id] match text::query($__query)) | order(_score desc) [_score > 0] [0...$__limit] {_score, _type, _id, _originalId}"
`,
)

Expand All @@ -58,19 +58,20 @@ describe('createSearchQuery', () => {

describe('searchOptions', () => {
it('should include drafts by default', () => {
const {options} = createSearchQuery(
const {query, options} = createSearchQuery(
{
query: 'term0',
types: [testType],
},
'',
)

expect(options.perspective).toBe('previewDrafts')
expect(query).not.toMatch(`!(_id in path('drafts.**'))`)
expect(options.perspective).toBe('raw')
})

it('should exclude drafts when configured', () => {
const {options} = createSearchQuery(
const {query, options} = createSearchQuery(
{
query: 'term0',
types: [testType],
Expand All @@ -79,50 +80,33 @@ describe('createSearchQuery', () => {
{includeDrafts: false},
)

expect(options.perspective).toBe('published')
expect(query).toMatch(`!(_id in path('drafts.**'))`)
expect(options.perspective).toBe('raw')
})

it('should give `perspective` precedence over `includeDrafts`', () => {
const {options: optionsIncludeDrafts} = createSearchQuery(
{
query: 'term0',
types: [testType],
},
'',
{
includeDrafts: true,
perspective: 'published',
},
)

expect(optionsIncludeDrafts.perspective).toBe('published')

const {options: optionsExcludeDrafts} = createSearchQuery(
it('should use `raw` perspective when no perspective provided', () => {
const {options} = createSearchQuery(
{
query: 'term0',
types: [testType],
},
'',
{
includeDrafts: false,
perspective: 'drafts',
},
)

expect(optionsExcludeDrafts.perspective).toBe('drafts')
expect(options.perspective).toBe('raw')
})

it('should add no perspective parameter when `raw` perspective provided', () => {
it('should use `raw` perspective when empty perspective array provided', () => {
const {options} = createSearchQuery(
{
query: 'term0',
types: [testType],
},
'',
{perspective: 'raw'},
{perspective: []},
)

expect(options.perspective).toBeUndefined()
expect(options.perspective).toBe('raw')
})

it('should use provided limit (plus one to determine existence of next page)', () => {
Expand Down Expand Up @@ -150,9 +134,7 @@ describe('createSearchQuery', () => {
{filter: 'randomCondition == $customParam', params: {customParam: 'custom'}},
)

expect(query).toContain(
'*[_type in $__types && (randomCondition == $customParam) && !(_id in path("versions.**"))]',
)
expect(query).toContain('*[_type in $__types && (randomCondition == $customParam)]')
expect(params.customParam).toEqual('custom')
})

Expand Down Expand Up @@ -188,7 +170,7 @@ describe('createSearchQuery', () => {

expect(query).toMatchInlineSnapshot(`
"// findability-mvi:5
*[_type in $__types && [@, _id] match text::query($__query) && !(_id in path("versions.**"))] | order(exampleField desc) [0...$__limit] {exampleField, _type, _id, _originalId}"
*[_type in $__types && [@, _id] match text::query($__query)] | order(exampleField desc) [0...$__limit] {exampleField, _type, _id, _originalId}"
`)

expect(query).toContain('| order(exampleField desc)')
Expand Down Expand Up @@ -222,7 +204,7 @@ describe('createSearchQuery', () => {

expect(query).toMatchInlineSnapshot(`
"// findability-mvi:5
*[_type in $__types && [@, _id] match text::query($__query) && !(_id in path("versions.**"))] | order(exampleField desc,anotherExampleField asc,lower(mapWithField) asc) [0...$__limit] {exampleField, anotherExampleField, mapWithField, _type, _id, _originalId}"
*[_type in $__types && [@, _id] match text::query($__query)] | order(exampleField desc,anotherExampleField asc,lower(mapWithField) asc) [0...$__limit] {exampleField, anotherExampleField, mapWithField, _type, _id, _originalId}"
`)

expect(query).toContain(
Expand All @@ -241,7 +223,7 @@ describe('createSearchQuery', () => {

expect(query).toMatchInlineSnapshot(`
"// findability-mvi:5
*[_type in $__types && !(_id in path("versions.**"))] | score(boost(_type in ["basic-schema-test"] && title match text::query($__query), 10), [@, _id] match text::query($__query)) | order(_score desc) [_score > 0] [0...$__limit] {_score, _type, _id, _originalId}"
*[_type in $__types] | score(boost(_type in ["basic-schema-test"] && title match text::query($__query), 10), [@, _id] match text::query($__query)) | order(_score desc) [_score > 0] [0...$__limit] {_score, _type, _id, _originalId}"
`)

expect(query).toContain('| order(_score desc)')
Expand Down Expand Up @@ -319,7 +301,7 @@ describe('createSearchQuery', () => {

expect(query).toMatchInlineSnapshot(`
"// findability-mvi:5
*[_type in $__types && !(_id in path("versions.**"))] | score(boost(_type in ["numbers-in-path"] && cover[].cards[].title match text::query($__query), 5), [@, _id] match text::query($__query)) | order(_score desc) [_score > 0] [0...$__limit] {_score, _type, _id, _originalId}"
*[_type in $__types] | score(boost(_type in ["numbers-in-path"] && cover[].cards[].title match text::query($__query), 5), [@, _id] match text::query($__query)) | order(_score desc) [_score > 0] [0...$__limit] {_score, _type, _id, _originalId}"
`)

expect(query).toContain('cover[].cards[].title match text::query($__query), 5)')
Expand Down
32 changes: 14 additions & 18 deletions packages/sanity/src/core/search/groq2024/createSearchQuery.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {type ClientPerspective} from '@sanity/client'
import {DEFAULT_MAX_FIELD_DEPTH} from '@sanity/schema/_internal'
import {type CrossDatasetType, type SchemaType} from '@sanity/types'
import {groupBy} from 'lodash'
Expand Down Expand Up @@ -68,8 +69,6 @@ export function createSearchQuery(
)
.filter(({paths}) => paths.length !== 0)

const isRaw = isPerspectiveRaw(perspective)

// Note: Computing this is unnecessary when `!isScored`.
const flattenedSpecs = specs
.map(({typeName, paths}) => paths.map((path) => ({...path, typeName})))
Expand All @@ -93,13 +92,26 @@ export function createSearchQuery(
const sortOrder = options?.sort ?? [{field: '_score', direction: 'desc'}]
const isScored = sortOrder.some(({field}) => field === '_score')

let activePerspective: ClientPerspective | undefined = perspective

// No perspective, or empty perspective array, provided.
if (
typeof perspective === 'undefined' ||
(Array.isArray(perspective) && perspective.length === 0)
) {
activePerspective = 'raw'
}

const isRaw = isPerspectiveRaw(activePerspective)

const filters: string[] = [
'_type in $__types',
// If the search request doesn't use scoring, directly filter documents.
isScored ? [] : baseMatch,
options.filter ? `(${options.filter})` : [],
searchTerms.filter ? `(${searchTerms.filter})` : [],
isRaw ? [] : '!(_id in path("versions.**"))',
includeDrafts === false ? `!(_id in path('drafts.**'))` : [],
options.cursor ?? [],
].flat()

Expand Down Expand Up @@ -130,22 +142,6 @@ export function createSearchQuery(
.map((s) => `// ${s}`)
.join('\n')

let activePerspective: string | string[] | undefined

switch (true) {
// Raw perspective provided.
case isRaw:
activePerspective = undefined
break
// Any other perspective provided.
case typeof perspective !== 'undefined':
activePerspective = perspective
break
// No perspective provided.
default:
activePerspective = includeDrafts ? 'previewDrafts' : 'published'
}

return {
query: [pragma, query].join('\n'),
options: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import {
type CurrentUser,
type SanityDocumentLike,
type SchemaType,
type SearchStrategy,
} from '@sanity/types'
import {type CurrentUser, type SchemaType, type SearchStrategy} from '@sanity/types'

import {type SearchHit, type SearchTerms} from '../../../../../../search'
import {collate} from '../../../../../../util'
import {removeDupes} from '../../../../../../util/draftUtils'
import {type RecentSearch} from '../../datastores/recentSearches'
import {type SearchFieldDefinitionDictionary} from '../../definitions/fields'
import {type SearchFilterDefinitionDictionary} from '../../definitions/filters'
Expand Down Expand Up @@ -231,9 +226,11 @@ export function searchReducer(state: SearchReducerState, action: SearchAction):
...state.result,
error: null,
hasLocal: true,
hits: collateHits(
state.result.hasLocal ? [...state.result.hits, ...action.hits] : action.hits,
),
hits: state.result.hasLocal
? removeDupes([...state.result.hits, ...action.hits].map(({hit}) => hit)).map(
(hit) => ({hit}),
)
: action.hits,
loaded: true,
loading: false,
},
Expand Down Expand Up @@ -597,8 +594,3 @@ function stripRecent(terms: RecentSearch | SearchTerms) {
}
return terms
}

function collateHits(hits: SearchHit[]) {
const collated = collate(hits.map((h) => h.hit))
return collated.map((doc) => ({hit: (doc.draft || doc.published)! as SanityDocumentLike}))
}
25 changes: 21 additions & 4 deletions packages/sanity/src/core/util/__tests__/draftUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,30 @@ import {
test('collate()', () => {
const foo = {_type: 'foo', _id: 'foo'}
const fooDraft = {_type: 'foo', _id: 'drafts.foo'}
const fooAlphaVersion = {_type: 'foo', _id: 'versions.alpha.foo'}
const fooBetaVersion = {_type: 'foo', _id: 'versions.beta.foo'}
const barDraft = {_type: 'foo', _id: 'drafts.bar'}
const baz = {_type: 'foo', _id: 'baz'}

expect(collate([foo, fooDraft, barDraft, baz])).toEqual([
{type: 'foo', id: 'foo', draft: fooDraft, published: foo},
{type: 'foo', id: 'bar', draft: barDraft},
{type: 'foo', id: 'baz', published: baz},
expect(collate([foo, fooDraft, barDraft, baz, fooAlphaVersion, fooBetaVersion])).toEqual([
{
type: 'foo',
id: 'foo',
draft: fooDraft,
published: foo,
versions: [
{
_id: 'versions.alpha.foo',
_type: 'foo',
},
{
_id: 'versions.beta.foo',
_type: 'foo',
},
],
},
{type: 'foo', id: 'bar', draft: barDraft, versions: []},
{type: 'foo', id: 'baz', published: baz, versions: []},
])
})

Expand Down
25 changes: 21 additions & 4 deletions packages/sanity/src/core/util/draftUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export interface CollatedHit<T extends {_id: string} = {_id: string}> {
type: string
draft?: T
published?: T
versions: T[]
}

/** @internal */
Expand All @@ -218,12 +219,28 @@ export function collate<T extends {_id: string; _type: string}>(documents: T[]):
const publishedId = getPublishedId(doc._id)
let entry = res.get(publishedId)
if (!entry) {
entry = {id: publishedId, type: doc._type, published: undefined, draft: undefined}
entry = {
id: publishedId,
type: doc._type,
published: undefined,
draft: undefined,
versions: [],
}
res.set(publishedId, entry)
}

// note: this attaches versions to the `draft` property, which is likely ok for most practical purposes
entry[publishedId === doc._id ? 'published' : 'draft'] = doc
if (isPublishedId(doc._id)) {
entry.published = doc
}

if (isDraftId(doc._id)) {
entry.draft = doc
}

if (isVersionId(doc._id)) {
entry.versions.push(doc)
}

return res
}, new Map())

Expand All @@ -234,6 +251,6 @@ export function collate<T extends {_id: string; _type: string}>(documents: T[]):
// Removes published documents that also has a draft
export function removeDupes(documents: SanityDocumentLike[]): SanityDocumentLike[] {
return collate(documents)
.map((entry) => entry.draft || entry.published)
.map((entry) => entry.draft || entry.published || entry.versions[0])
.filter(isNonNullable)
}
6 changes: 2 additions & 4 deletions packages/sanity/src/structure/panes/documentList/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from '@sanity/types'
import * as PathUtils from '@sanity/util/paths'
import {type ExprNode, parse} from 'groq-js'
import {collate, getPublishedId, isVersionId} from 'sanity'
import {collate, getPublishedId} from 'sanity'

import {type DocumentListPaneItem, type SortOrder} from './types'

Expand All @@ -20,15 +20,13 @@ export function getDocumentKey(value: DocumentListPaneItem, index: number): stri

export function removePublishedWithDrafts(documents: SanityDocumentLike[]): DocumentListPaneItem[] {
return collate(documents).map((entry) => {
const doc = entry.draft || entry.published
const isVersion = doc?.id && isVersionId(doc._id)
const doc = entry.draft || entry.published || entry.versions[0]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could perhaps consider adding a preference for what version to chose, but probably better to defer that to when we need it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that could be a good idea. Settled for this now just to make things a bit clearer.

const hasDraft = Boolean(entry.draft)

return {
...doc,
hasPublished: !!entry.published,
hasDraft,
isVersion,
}
}) as any
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {type SearchSort} from 'sanity'
export interface DocumentListPaneItem extends SanityDocumentLike {
hasPublished: boolean
hasDraft: boolean
isVersion: boolean
}

export type SortOrder = {
Expand Down
Loading