Skip to content

Commit

Permalink
Move search logic into parts (#1105)
Browse files Browse the repository at this point in the history
* [base] Move search logic into parts

* [base] Add quard for supporting queries without terms

* [base] Add support for search options

* [default-layout] Pass search options
  • Loading branch information
mariuslundgard authored and bjoerge committed Dec 17, 2018
1 parent 3d2abf6 commit 87a49db
Show file tree
Hide file tree
Showing 8 changed files with 145 additions and 47 deletions.
32 changes: 32 additions & 0 deletions packages/@sanity/base/sanity.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@
"name": "part:@sanity/base/component",
"description": "React Storybook (https://github.com/kadirahq/react-storybook) stories"
},
{
"name": "part:@sanity/base/search",
"description": "Parse the search query and fetch search results from the dataset"
},
{
"name": "part:@sanity/base/search/fetchSearchResults",
"description": "Fetch search results from the dataset"
},
{
"name": "part:@sanity/base/search/parseSearchQuery",
"description": "Parse a search query string"
},
{
"name": "part:@sanity/base/search/prepareSearchResults",
"description": "Prepare array of search results"
},
{
"name": "part:@sanity/base/theme/variables-style",
"description": "Base theme variables for Sanity. Dont override this unless you provide all the new variables"
Expand Down Expand Up @@ -252,6 +268,22 @@
"implements": "part:@sanity/base/schema-creator",
"path": "schema/createSchema.js"
},
{
"implements": "part:@sanity/base/search",
"path": "search/index.js"
},
{
"implements": "part:@sanity/base/search/fetchSearchResults",
"path": "search/fetchSearchResults.js"
},
{
"implements": "part:@sanity/base/search/parseSearchQuery",
"path": "search/parseSearchQuery.js"
},
{
"implements": "part:@sanity/base/search/prepareSearchResults",
"path": "search/prepareSearchResults.js"
},
{
"implements": "part:@sanity/base/util/draft-utils",
"path": "util/draftUtils.js"
Expand Down
60 changes: 60 additions & 0 deletions packages/@sanity/base/src/search/fetchSearchResults.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {flow, compact, flatten, union, uniq} from 'lodash'
import client from 'part:@sanity/base/client?'
import schema from 'part:@sanity/base/schema?'
import {
escapeField,
fieldNeedsEscape,
getSearchableTypeNames,
joinPath
} from 'part:@sanity/base/util/search-utils'

const combineFields = flow([flatten, union, compact])

function mapToEscapedProjectionFieldName(fieldName) {
if (fieldNeedsEscape(fieldName)) return `"${fieldName}":${escapeField(fieldName)}`

return fieldName
}

function fetchSearchResults(query, opts = {}) {
if (!client) throw new Error('Sanity client is missing')

const typeNames = opts.types || getSearchableTypeNames()
const types = typeNames.map(typeName => schema.get(typeName))

const groqParams = query.terms.reduce(
(acc, term, i) => {
acc[`t${i}`] = `${term}*` // "t" is short for term
return acc
},
{
limit: opts.limit || 100
}
)

const groqFilters = query.groqFilters ? query.groqFilters.slice(0) : []

const uniqueFields = combineFields(
types.map(type => (type.__unstable_searchFields || []).map(joinPath))
)

const constraints = query.terms.map((term, i) =>
uniqueFields.map(joinedPath => `${joinedPath} match $t${i}`)
)

if (constraints.length) {
groqFilters.push(constraints.map(constraint => `(${constraint.join('||')})`).join('&&'))
}

const groqFilterString = `(${groqFilters.join(')&&(')})`

const fields = uniq(['_id', '_type'].concat(opts.fields || [])).map(
mapToEscapedProjectionFieldName
)

const groqQuery = `*[${groqFilterString}][0...$limit]{${fields.join(',')}}`

return client.observable.fetch(groqQuery, groqParams)
}

export default fetchSearchResults
14 changes: 14 additions & 0 deletions packages/@sanity/base/src/search/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import fetchSearchResults from 'part:@sanity/base/search/fetchSearchResults'
import parseSearchQuery from 'part:@sanity/base/search/parseSearchQuery'
import prepareSearchResults from 'part:@sanity/base/search/prepareSearchResults'
import {map} from 'rxjs/operators'

function search(queryStr, opts) {
const query = parseSearchQuery(queryStr, opts)

return fetchSearchResults(query, opts).pipe(
map(results => prepareSearchResults(results, query, opts))
)
}

export default search
11 changes: 11 additions & 0 deletions packages/@sanity/base/src/search/parseSearchQuery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
function parseSearchQuery(queryStr, opts = {}) {
const terms = queryStr.split(/\s+/).filter(Boolean)

return {
original: queryStr,
terms,
groqFilters: []
}
}

export default parseSearchQuery
6 changes: 6 additions & 0 deletions packages/@sanity/base/src/search/prepareSearchResults.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
function prepareSearchResults(results, query, opts = {}) {
// This function may be extended by overriding using the part system
return results
}

export default prepareSearchResults
13 changes: 13 additions & 0 deletions packages/@sanity/base/src/util/draftUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,16 @@ export function createPublishedFrom(document) {
...document
}
}

// Removes published documents that also has a draft
export function removeDupes(documents) {
const drafts = documents.map(doc => doc._id).filter(isDraftId)

return documents.filter(doc => {
const draftId = getDraftId(doc._id)
const publishedId = getPublishedId(doc._id)
const hasDraft = drafts.includes(draftId)
const isPublished = doc._id === publishedId
return isPublished ? !hasDraft : true
})
}
6 changes: 6 additions & 0 deletions packages/@sanity/base/src/util/searchUtils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import schema from 'part:@sanity/base/schema?'

const GROQ_KEYWORDS = ['match', 'in', 'asc', 'desc', 'true', 'false', 'null']
const VALID_FIELD = /^[a-zA-Z_][a-zA-Z0-9_]*$/

Expand All @@ -21,3 +23,7 @@ export const joinPath = pathArray =>
}
return isFirst ? pathSegment : `${prev}.${pathSegment}`
}, '')

export function getSearchableTypeNames() {
return schema.getTypeNames().filter(typeName => !typeName.startsWith('sanity.'))
}
50 changes: 3 additions & 47 deletions packages/@sanity/default-layout/src/components/SearchContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@
import PropTypes from 'prop-types'
import React from 'react'
import schema from 'part:@sanity/base/schema?'
import client from 'part:@sanity/base/client?'
import Preview from 'part:@sanity/base/preview?'
import {joinPath} from 'part:@sanity/base/util/search-utils'
import {getPublishedId, isDraftId, getDraftId} from 'part:@sanity/base/util/draft-utils'
import {removeDupes} from 'part:@sanity/base/util/draft-utils'
import {Subject} from 'rxjs'
import {IntentLink} from 'part:@sanity/base/router'
import {flow, compact, flatten, union} from 'lodash'
import search from 'part:@sanity/base/search'
import Ink from 'react-ink'
import SearchField from './SearchField'
import SearchResults from './SearchResults'
Expand All @@ -21,48 +19,6 @@ import resultsStyles from './styles/SearchResults.css'
// openSearch: isKeyHotkey('ctrl+t')
// }

// Removes published documents that also has a draft
function removeDupes(documents) {
const drafts = documents.map(doc => doc._id).filter(isDraftId)

return documents.filter(doc => {
const draftId = getDraftId(doc._id)
const publishedId = getPublishedId(doc._id)
const hasDraft = drafts.includes(draftId)
const isPublished = doc._id === publishedId
return isPublished ? !hasDraft : true
})
}

const combineFields = flow([flatten, union, compact])

function search(query) {
if (!client) {
throw new Error('Sanity client is missing')
}

const candidateTypes = schema
.getTypeNames()
.filter(typeName => !typeName.startsWith('sanity.'))
.map(typeName => schema.get(typeName))

const terms = query.split(/\s+/).filter(Boolean)

const params = terms.reduce((acc, term, i) => {
acc[`t${i}`] = `${term}*`
return acc
}, {})

const uniqueFields = combineFields(
candidateTypes.map(type => (type.__unstable_searchFields || []).map(joinPath))
)
const constraints = terms.map((term, i) =>
uniqueFields.map(joinedPath => `${joinedPath} match $t${i}`)
)
const constraintString = constraints.map(constraint => `(${constraint.join('||')})`).join('&&')
return client.observable.fetch(`*[${constraintString}][0...100] {_id, _type}`, params)
}

class SearchContainer extends React.PureComponent {
static propTypes = {
onOpen: PropTypes.func.isRequired,
Expand Down Expand Up @@ -121,7 +77,7 @@ class SearchContainer extends React.PureComponent {
})
}),
debounceTime(100),
switchMap(search),
switchMap(queryStr => search(queryStr, {limit: 100})),
// we need this filtering because the search may return documents of types not in schema
map(hits => hits.filter(hit => schema.has(hit._type))),
map(removeDupes),
Expand Down

0 comments on commit 87a49db

Please sign in to comment.