Skip to content

Commit

Permalink
feat: add groq2024 search strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
juice49 committed Dec 10, 2024
1 parent a303002 commit 13f2e48
Show file tree
Hide file tree
Showing 13 changed files with 1,039 additions and 10 deletions.
2 changes: 1 addition & 1 deletion packages/@sanity/types/src/search/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* @public
*/
export const searchStrategies = ['groqLegacy', 'textSearch'] as const
export const searchStrategies = ['groqLegacy', 'textSearch', 'groq2024'] as const

/**
* @public
Expand Down
2 changes: 2 additions & 0 deletions packages/sanity/src/core/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,8 @@ export interface PluginOptions {
* - `"groqLegacy"` (default): Use client-side tokenization and schema introspection to search
* using the GROQ Query API.
* - `"textSearch"` (deprecated): Perform full text searching using the Text Search API.
* - `"groq2024"`: (experimental) Perform full text searching using the GROQ Query API and its
* new `text::matchQuery` function.
*/
strategy?: SearchStrategy

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import {
type CrossDatasetType,
type SchemaType,
type SearchConfiguration,
type SlugSchemaType,
} from '@sanity/types'
import {toString as pathToString} from '@sanity/util/paths'

import {isRecord} from '../../util'
import {type SearchPath, type SearchSpec} from './types'

interface SearchWeightEntry {
path: string
weight: number
type?: 'string' | 'pt'
}

const CACHE = new WeakMap<SchemaType | CrossDatasetType, SearchSpec>()

const PREVIEW_FIELD_WEIGHT_MAP = {
title: 10,
subtitle: 5,
description: 1.5,
}
const BASE_WEIGHTS: Record<string, Omit<SearchWeightEntry, 'path'>> = {
_id: {weight: 1},
_type: {weight: 1},
}

// Any object type whose fields should not be considered for custom weighting.
//
// Search may still match on their fields, but will not traverse their schema to find custom
// weights.
//
// Some types, such as `slug`, may instead determine weights using a specialised implementation.
const ignoredBuiltInObjectTypes = ['reference', 'crossDatasetReference', 'slug']

const getTypeChain = (type: SchemaType | undefined): SchemaType[] =>
type ? [type, ...getTypeChain(type.type)] : []

const isPtField = (type: SchemaType | undefined) =>
type?.jsonType === 'array' &&
type.of.some((arrType) => getTypeChain(arrType).some(({name}) => name === 'block'))

const isStringField = (schemaType: SchemaType | undefined): boolean =>
schemaType ? schemaType?.jsonType === 'string' : false

const isSlugField = (schemaType: SchemaType | undefined): schemaType is SlugSchemaType => {
const typeChain = getTypeChain(schemaType)
return typeChain.some(({jsonType, name}) => jsonType === 'object' && name === 'slug')
}

const isSearchConfiguration = (options: unknown): options is SearchConfiguration =>
isRecord(options) && 'search' in options && isRecord(options.search)

function isSchemaType(input: SchemaType | CrossDatasetType | undefined): input is SchemaType {
return typeof input !== 'undefined' && 'name' in input
}

function getFullyQualifiedPath(schemaType: SchemaType, path: string): string {
// Slug field weights should be applied to the object's `current` field.
if (isSlugField(schemaType)) {
return [path, 'current'].join('.')
}

return path
}

function getLeafWeights(
schemaType: SchemaType | CrossDatasetType | undefined,
maxDepth: number,
getWeight: (schemaType: SchemaType, path: string) => number | null,
): Record<string, SearchWeightEntry> {
function traverse(
type: SchemaType | undefined,
path: string,
depth: number,
): SearchWeightEntry[] {
if (!type) return []
if (depth > maxDepth) return []

const typeChain = getTypeChain(type)

if (isStringField(type) || isPtField(type)) {
const weight = getWeight(type, path)

if (typeof weight !== 'number') return []
return [{path, weight}]
}

if (isSlugField(type)) {
const weight = getWeight(type, path)
if (typeof weight !== 'number') return []
return [{path: getFullyQualifiedPath(type, path), weight}]
}

const results: SearchWeightEntry[] = []

const objectTypes = typeChain.filter(
(t): t is Extract<SchemaType, {jsonType: 'object'}> =>
t.jsonType === 'object' &&
!!t.fields?.length &&
!ignoredBuiltInObjectTypes.includes(t.name),
)
for (const objectType of objectTypes) {
for (const field of objectType.fields) {
const nextPath = pathToString([path, field.name].filter(Boolean))
results.push(...traverse(field.type, nextPath, depth + 1))
}
}

const arrayTypes = typeChain.filter(
(t): t is Extract<SchemaType, {jsonType: 'array'}> =>
t.jsonType === 'array' && !!t.of?.length,
)
for (const arrayType of arrayTypes) {
for (const arrayItemType of arrayType.of) {
const nextPath = `${path}[]`
results.push(...traverse(arrayItemType, nextPath, depth + 1))
}
}

return results
}

// Cross Dataset Reference are not part of the schema, so we should not attempt to reconcile them.
if (!isSchemaType(schemaType)) {
return {}
}

return traverse(schemaType, '', 0).reduce<Record<string, SearchWeightEntry>>(
(acc, {path, weight, type}) => {
acc[path] = {weight, type, path}
return acc
},
{},
)
}

const getUserSetWeight = (schemaType: SchemaType) => {
const searchOptions = getTypeChain(schemaType)
.map((type) => type.options)
.find(isSearchConfiguration)

return typeof searchOptions?.search?.weight === 'number' ? searchOptions.search.weight : null
}

const getHiddenWeight = (schemaType: SchemaType) => {
const hidden = getTypeChain(schemaType).some((type) => type.hidden)
return hidden ? 0 : null
}

const getDefaultWeights = (schemaType: SchemaType) => {
// if there is no user set weight or a `0` weight due to be hidden,
// then we can return the default weight of `1`
const result = getUserSetWeight(schemaType) ?? getHiddenWeight(schemaType)
return typeof result === 'number' ? null : 1
}

const getPreviewWeights = (
schemaType: SchemaType | CrossDatasetType | undefined,
maxDepth: number,
isCrossDataset?: boolean,
): Record<string, SearchWeightEntry> | null => {
const select = schemaType?.preview?.select
if (!select) return null

const selectionKeysBySelectionPath = Object.fromEntries(
Object.entries(select).map(([selectionKey, selectionPath]) => [
// replace indexed paths with `[]`
// e.g. `arrayOfObjects.0.myField` becomes `arrayOfObjects[].myField`
selectionPath.replace(/\.\d+/g, '[]'),
selectionKey,
]),
)

const defaultWeights = getLeafWeights(schemaType, maxDepth, getDefaultWeights)
const nestedWeightsBySelectionPath = Object.fromEntries(
Object.entries(defaultWeights)
.map(([path, {type}]) => ({path, type}))
.filter(({path}) => selectionKeysBySelectionPath[path])
.map(({path, type}) => [
path,
{
type,
weight:
PREVIEW_FIELD_WEIGHT_MAP[
selectionKeysBySelectionPath[path] as keyof typeof PREVIEW_FIELD_WEIGHT_MAP
],
},
]),
)

if (isCrossDataset) {
return Object.fromEntries(
Object.entries(selectionKeysBySelectionPath).map(([path, previewFieldName]) => {
return [
path,
{
path,
type: 'string',
weight:
PREVIEW_FIELD_WEIGHT_MAP[previewFieldName as keyof typeof PREVIEW_FIELD_WEIGHT_MAP],
},
]
}),
)
}

return getLeafWeights(schemaType, maxDepth, (type, path) => {
const nested = nestedWeightsBySelectionPath[getFullyQualifiedPath(type, path)]
return nested ? nested.weight : null
})
}

interface DeriveSearchWeightsFromTypeOptions {
schemaType: SchemaType | CrossDatasetType
maxDepth: number
isCrossDataset?: boolean
processPaths?: (paths: SearchPath[]) => SearchPath[]
}

export function deriveSearchWeightsFromType2024({
schemaType,
maxDepth,
isCrossDataset,
processPaths = (paths) => paths,
}: DeriveSearchWeightsFromTypeOptions): SearchSpec {
const cached = CACHE.get(schemaType)
if (cached) return cached

const userSetWeights = getLeafWeights(schemaType, maxDepth, getUserSetWeight)
const hiddenWeights = getLeafWeights(schemaType, maxDepth, getHiddenWeight)
const defaultWeights = getLeafWeights(schemaType, maxDepth, getDefaultWeights)
const previewWeights = getPreviewWeights(schemaType, maxDepth, isCrossDataset)

const weights: Record<string, Omit<SearchWeightEntry, 'path'>> = {
...BASE_WEIGHTS,
...defaultWeights,
...hiddenWeights,
...previewWeights,
...userSetWeights,
}

const result = {
typeName: isSchemaType(schemaType) ? schemaType.name : schemaType.type,
paths: processPaths(
Object.entries(weights).map(([path, {weight}]) => ({
path,
weight,
})),
),
}

CACHE.set(schemaType, result)
return result
}
1 change: 1 addition & 0 deletions packages/sanity/src/core/search/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './deriveSearchWeightsFromType'
export * from './deriveSearchWeightsFromType2024'
export * from './getSearchableTypes'
export * from './types'
13 changes: 12 additions & 1 deletion packages/sanity/src/core/search/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,18 @@ export interface WeightedSearchResults {
/**
* @internal
*/
export type SearchStrategyFactory<TResult extends TextSearchResults | WeightedSearchResults> = (
export interface Groq2024SearchResults {
type: 'groq2024'
hits: SearchHit[]
nextCursor?: string
}

/**
* @internal
*/
export type SearchStrategyFactory<
TResult extends TextSearchResults | WeightedSearchResults | Groq2024SearchResults,
> = (
types: (SchemaType | CrossDatasetType)[],
client: SanityClient,
commonOpts: SearchFactoryOptions,
Expand Down
74 changes: 74 additions & 0 deletions packages/sanity/src/core/search/groq2024/createGroq2024Search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {type CrossDatasetType, type SanityDocumentLike, type SchemaType} from '@sanity/types'
import {map} from 'rxjs'

import {
type Groq2024SearchResults,
type SearchStrategyFactory,
type SearchTerms,
} from '../common/types'
import {createSearchQuery} from './createSearchQuery'
import {getNextCursor} from './getNextCursor'

function getSearchTerms(
searchParams: string | SearchTerms,
types: (SchemaType | CrossDatasetType)[],
) {
if (typeof searchParams === 'string') {
return {
query: searchParams,
types: types,
}
}
return searchParams.types.length ? searchParams : {...searchParams, types}
}

/**
* @internal
*/
export const createGroq2024Search: SearchStrategyFactory<Groq2024SearchResults> = (
typesFromFactory,
client,
factoryOptions,
) => {
return function search(searchParams, searchOptions = {}) {
const searchTerms = getSearchTerms(searchParams, typesFromFactory)

const mergedOptions = {
...factoryOptions,
...searchOptions,
}

const {query, params, options, sortOrder} = createSearchQuery(
searchTerms,
searchParams,
mergedOptions,
)

return client.observable
.withConfig({
// The GROQ functions that power `groq2024` are currently only available using API `vX`.
//
// TODO: Switch to stable API version before `groq2024` general availability.
apiVersion: 'vX',
})
.fetch<SanityDocumentLike[]>(query, params, options)
.pipe(
map((hits) => {
const hasNextPage =
typeof searchOptions.limit !== 'undefined' && hits.length > searchOptions.limit

// Search overfetches by 1 to determine whether there is another page to fetch. Therefore,
// the penultimate result must be used to determine the start of the next page.
const lastResult = hasNextPage ? hits.at(-2) : hits.at(-1)

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: hasNextPage ? getNextCursor({lastResult, sortOrder}) : undefined,
}
}),
)
}
}
Loading

0 comments on commit 13f2e48

Please sign in to comment.