sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
491 lines (444 loc) • 15 kB
text/typescript
/* eslint-disable camelcase */
import {describe, expect, it, test} from '@jest/globals'
import {Schema} from '@sanity/schema'
import {defineArrayMember, defineField, defineType} from '@sanity/types'
import {FINDABILITY_MVI} from '../constants'
import {
createSearchQuery,
DEFAULT_LIMIT,
extractTermsFromQuery,
tokenize,
} from './createSearchQuery'
const testType = Schema.compile({
types: [
defineType({
name: 'basic-schema-test',
type: 'document',
preview: {
select: {
title: 'title',
},
},
fields: [
defineField({
name: 'title',
type: 'string',
options: {
search: {
weight: 10,
},
},
}),
],
}),
],
}).get('basic-schema-test')
describe('createSearchQuery', () => {
describe('searchTerms', () => {
it('should create query for basic type', () => {
const {query, params} = createSearchQuery({
query: 'test',
types: [testType],
})
expect(query).toEqual(
`// findability-mvi:${FINDABILITY_MVI}\n` +
'*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0)]' +
'| order(_id asc)' +
'[0...$__limit]' +
'{_type, _id, ...select(_type == "basic-schema-test" => { "w0": _id,"w1": _type,"w2": title })}',
)
expect(params).toEqual({
t0: 'test*',
__types: ['basic-schema-test'],
__limit: DEFAULT_LIMIT,
})
})
it('should OR fields together per term', () => {
const {query} = createSearchQuery({
query: 'term0',
types: [
Schema.compile({
types: [
defineType({
name: 'basic-schema-test',
type: 'document',
preview: {
select: {
title: 'title',
},
},
fields: [
defineField({
name: 'title',
type: 'string',
options: {
search: {
weight: 10,
},
},
}),
defineField({
name: 'object',
type: 'object',
fields: [
defineField({
name: 'field',
type: 'string',
options: {
search: {
weight: 5,
},
},
}),
],
}),
],
}),
],
}).get('basic-schema-test'),
],
})
expect(query).toContain(
'*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0 || object.field match $t0)]',
)
})
it('should have one match filter per term', () => {
const {query, params} = createSearchQuery({
query: 'term0 term1',
types: [testType],
})
expect(query).toContain(
'*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0) && (_id match $t1 || _type match $t1 || title match $t1)]',
)
expect(params.t0).toEqual('term0*')
expect(params.t1).toEqual('term1*')
})
it('should remove duplicate terms', () => {
const {params, terms} = createSearchQuery({
query: 'term term',
types: [testType],
})
expect(params.t0).toEqual('term*')
expect(params.t1).toBeUndefined()
expect(terms).toEqual(['term'])
})
it('should add extendedProjection to query', () => {
const {query} = createSearchQuery(
{
query: 'term',
types: [testType],
},
{
__unstable_extendedProjection: 'object{field}',
},
)
const result = [
`// findability-mvi:${FINDABILITY_MVI}\n` +
'*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0)]{_type, _id, object{field}}',
'|order(_id asc)[0...$__limit]',
'{_type, _id, ...select(_type == "basic-schema-test" => { "w0": _id,"w1": _type,"w2": title })}',
].join('')
expect(query).toBe(result)
})
})
describe('searchOptions', () => {
it('should exclude drafts when configured', () => {
const {query} = createSearchQuery(
{
query: 'term0',
types: [testType],
},
{includeDrafts: false},
)
expect(query).toContain("!(_id in path('drafts.**'))")
})
it('should use provided limit', () => {
const {params} = createSearchQuery(
{
query: 'term0',
types: [testType],
},
{
limit: 30,
},
)
expect(params.__limit).toEqual(30)
})
it('should add configured filter and params', () => {
const {query, params} = createSearchQuery(
{
query: 'term',
types: [testType],
},
{filter: 'randomCondition == $customParam', params: {customParam: 'custom'}},
)
expect(query).toContain(
'*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0) && (randomCondition == $customParam)]',
)
expect(params.customParam).toEqual('custom')
})
it('should add configured common limit', () => {
const {params} = createSearchQuery(
{
query: 'term',
types: [testType],
},
{limit: 50},
)
expect(params.__limit).toEqual(50)
})
it('should use configured tag', () => {
const {options} = createSearchQuery(
{
query: 'term',
types: [testType],
},
{tag: 'customTag'},
)
expect(options.tag).toEqual('customTag')
})
it('should use configured sort field and direction', () => {
const {query} = createSearchQuery(
{
query: 'test',
types: [testType],
},
{
sort: [
{
direction: 'desc',
field: 'exampleField',
},
],
},
)
expect(query).toEqual(
`// findability-mvi:${FINDABILITY_MVI}\n` +
'*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0)]' +
'| order(exampleField desc)' +
'[0...$__limit]' +
'{_type, _id, ...select(_type == "basic-schema-test" => { "w0": _id,"w1": _type,"w2": title })}',
)
})
it('should use multiple sort fields and directions', () => {
const {query} = createSearchQuery(
{
query: 'test',
types: [testType],
},
{
sort: [
{
direction: 'desc',
field: 'exampleField',
},
{
direction: 'asc',
field: 'anotherExampleField',
},
{
direction: 'asc',
field: 'mapWithField',
mapWith: 'lower',
},
],
},
)
const result = [
`// findability-mvi:${FINDABILITY_MVI}\n`,
'*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0)]| ',
'order(exampleField desc,anotherExampleField asc,lower(mapWithField) asc)',
'[0...$__limit]{_type, _id, ...select(_type == "basic-schema-test" => { "w0": _id,"w1": _type,"w2": title })}',
].join('')
expect(query).toEqual(result)
})
it('should order results by _id ASC if no sort field and direction is configured', () => {
const {query} = createSearchQuery({
query: 'test',
types: [testType],
})
expect(query).toEqual(
`// findability-mvi:${FINDABILITY_MVI}\n` +
'*[_type in $__types && (_id match $t0 || _type match $t0 || title match $t0)]' +
'| order(_id asc)' +
'[0...$__limit]' +
'{_type, _id, ...select(_type == "basic-schema-test" => { "w0": _id,"w1": _type,"w2": title })}',
)
})
it('should prepend comments (with new lines) if comments is configured', () => {
const {query} = createSearchQuery(
{
query: 'test',
types: [testType],
},
{
comments: ['foo=1', 'bar'], //'],
},
)
const splitQuery = query.split('\n')
expect(splitQuery[0]).toEqual(`// findability-mvi:${FINDABILITY_MVI}`)
expect(splitQuery[1]).toEqual('// foo=1')
expect(splitQuery[2]).toEqual('// bar')
})
})
describe('searchSpec', () => {
it('should include searchSpec for introspection/debug', () => {
const {searchSpec} = createSearchQuery(
{
query: 'term',
types: [testType],
},
{tag: 'customTag'},
)
expect(searchSpec).toEqual([
{
typeName: testType.name,
paths: [
{
weight: 1,
path: '_id',
},
{
weight: 1,
path: '_type',
},
{
weight: 10,
path: 'title',
},
],
},
])
})
})
describe('search config', () => {
it('should handle indexed array fields in an optimized manner', () => {
const {query} = createSearchQuery({
query: 'term0 term1',
types: [
Schema.compile({
types: [
defineType({
name: 'numbers-in-path',
type: 'document',
fields: [
defineField({
name: 'cover',
type: 'array',
of: [
defineArrayMember({
type: 'object',
fields: [
defineField({
name: 'cards',
type: 'array',
of: [
defineArrayMember({
type: 'object',
fields: [
defineField({
name: 'title',
type: 'string',
options: {
search: {
weight: 1,
},
},
}),
],
}),
],
}),
],
}),
],
}),
],
}),
],
}).get('numbers-in-path'),
],
})
expect(query).toEqual(
/* Putting [number] in the filter of a query makes the whole query unoptimized by content-lake, killing performance.
* As a workaround, we replace numbers with [] array syntax, so we at least get hits when the path matches anywhere in the array.
* This is an improvement over before, where an illegal term was used (number-as-string, ala ["0"]),
* which lead to no hits at all. */
`// findability-mvi:${FINDABILITY_MVI}\n` +
'*[_type in $__types && (_id match $t0 || _type match $t0 || cover[].cards[].title match $t0) && (_id match $t1 || _type match $t1 || cover[].cards[].title match $t1)]' +
'| order(_id asc)' +
'[0...$__limit]' +
// at this point we could refilter using cover[0].cards[0].title.
// This solution was discarded at it would increase the size of the query payload by up to 50%
// we still map out the path with number
'{_type, _id, ...select(_type == "numbers-in-path" => { "w0": _id,"w1": _type,"w2": cover[].cards[].title })}',
)
})
})
})
describe('extractTermsFromQuery', () => {
describe('should handle orphaned double quotes', () => {
const tests: [string, string[]][] = [
[`"foo bar`, ['foo', 'bar']],
[`foo bar"`, ['foo', 'bar']],
[`foo "bar`, ['foo', 'bar']],
]
it.each(tests)('%s', (input, expected) => {
expect(extractTermsFromQuery(input)).toEqual(expected)
})
})
it('should treat single quotes as regular characters', () => {
const terms = extractTermsFromQuery(`'foo ' bar'`)
expect(terms).toEqual([`'foo`, `'`, `bar'`])
})
it('should tokenize all unquoted text', () => {
const terms = extractTermsFromQuery('foo bar')
expect(terms).toEqual(['foo', 'bar'])
})
it('should treat quoted text as a single token, retaining quotes', () => {
const terms = extractTermsFromQuery(`"foo bar" baz`)
expect(terms).toEqual([`"foo bar"`, 'baz'])
})
it('should strip quotes from text containing single words', () => {
const terms = extractTermsFromQuery(`"foo"`)
expect(terms).toEqual([`foo`])
})
})
describe('tokenize', () => {
const tests = [
{input: '', expected: []},
{input: 'foo', expected: ['foo']},
{input: '0foo', expected: ['0foo']},
{input: 'a16z', expected: ['a16z']},
{input: 'foo,,, , ,foo,bar', expected: ['foo', 'foo', 'bar']},
{input: 'pho-bar, foo-bar', expected: ['pho', 'bar', 'foo', 'bar']},
{input: '0 foo', expected: ['0', 'foo']},
{input: 'foo 🤪🤪🤪', expected: ['foo', '🤪🤪🤪']},
{input: 'foo 🤪🤪🤪 bar', expected: ['foo', '🤪🤪🤪', 'bar']},
{input: '1 2 3', expected: ['1', '2', '3']},
{input: 'foo, bar, baz', expected: ['foo', 'bar', 'baz']},
{input: 'foo , bar , baz', expected: ['foo', 'bar', 'baz']},
{input: 'a.b.c', expected: ['a.b.c']},
{input: 'sanity.io', expected: ['sanity.io']},
{input: 'fourty-two', expected: ['fourty', 'two']},
{
input: 'full stop. Then new beginning',
expected: ['full', 'stop', 'Then', 'new', 'beginning'],
},
{input: 'about .io domains', expected: ['about', 'io', 'domains']},
{input: 'abc -23 def', expected: ['abc', '23', 'def']},
{input: 'banana&[friends]\\/ barnåler', expected: ['banana', 'friends', 'barnåler']},
{input: 'banana&friends barnåler', expected: ['banana', 'friends', 'barnåler']},
{input: 'ban*ana*', expected: ['ban', 'ana']},
{
input: '한국인은 banana 동의하지 않는다',
expected: ['한국인은', 'banana', '동의하지', '않는다'],
},
{input: '한국인은 동의2하지', expected: ['한국인은', '동의2하지']},
]
tests.forEach(({input, expected}) => {
test('tokenization of search input string', () => {
expect(tokenize(input)).toEqual(expected)
})
})
})