123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- import { Document } from 'arangojs/documents'
- import { DocumentCollection } from 'arangojs/collection'
- import { AqlLiteral, GeneratedAqlQuery } from 'arangojs/aql'
- import { Database, aql } from 'arangojs'
- /**
- * A `CountFn` function returns the number of documents in a single collection matching search `terms` given.
- *
- * `count()` provides the standard implementation.
- */
- export type CountFn<T extends Searchable, S extends Searchable = T> = (
- terms?: Terms<Document<DeepNonNullable<S>>>,
- inject?: Inject
- ) => Promise<number>
- /**
- * Recursively renders all of a complex object's properties required, non-null, and non-undefined.
- *
- * Note: this type recurses through objects only, not arrays.
- */
- export type DeepNonNullable<T> = NonNullable<T> extends object ? { [P in keyof T]-?: DeepNonNullable<T[P]> } : T
- /** Query sort direction. */
- export type Direction = 'ASC' | 'DESC'
- /**
- * Simple search filter type in which any number of conditions can be specified for a presumed parameter.
- *
- * Multiple operators can be used in combination.
- * By default they will be combined into an `AND` filter.
- * Specify `mode: "OR"` to switch to an `OR` filter.
- *
- * Note: `LIKE`, `NOT LIKE`, and regular expression operators are only available if `T` extends `string`.
- */
- export type Filter<T> = {
- mode?: 'AND' | 'OR'
- eq?: T | null
- gt?: T
- gte?: T
- in?: T[]
- like?: T & string
- lt?: T
- lte?: T
- neq?: T | null
- nin?: T[]
- nlike?: T & string
- nreg?: T & string
- reg?: T & string
- }
- /**
- * A `FindFn` function returns the first document in a single collection matching search `terms` given.
- * A `sort` can be specified to control the result.
- *
- * `find()` provides the standard implementation.
- */
- export type FindFn<T extends Searchable, S extends Searchable = T> = (
- terms?: Terms<Document<DeepNonNullable<S>>>,
- sort?: Sort<Document<T>>[] | Sort<Document<T>>,
- inject?: Inject
- ) => Promise<Document<T> | undefined>
- /**
- * An `Inject` object allows modifying a search query with user-defined AQL literal expressions.
- * This can be useful for complex requirements, such as joining data or setting additional variables with `LET`.
- *
- * All injected strings are implicitly converted to AQL literals in the query, so should be manually escaped as needed.
- *
- * Note that while `CountFn` and `FindFn` do not use all the same parameters as `SearchFn`, all injections are still
- * supported so a single injection provider can be used for all query types.
- */
- export type Inject = {
- /** Injected before FILTER statements. */
- beforeFilter?: string
- /** Injected after FILTER statements, before SORT statements. */
- beforeSort?: string
- /** Injected after SORT statements, before LIMIT statement. */
- beforeLimit?: string
- /** Injected after all other statements. */
- after?: string
- }
- /**
- * Query limit.
- * Always a tuple, but the second value can be omitted.
- */
- export type Limit = [number, number?]
- /**
- * Searchable data type.
- * Essentially, any object is valid.
- */
- export type Searchable = Record<string, unknown>
- /**
- * Formulate search terms based on a complex data type.
- * Nested object types are preserved, while scalar properties are converted to `Filter` representations.
- */
- export type Terms<T> = {
- [P in keyof T]?: NonNullable<NonNullable<T[P]> extends object ? Terms<T[P]> : Filter<T[P]>>
- }
- /**
- * A `SearchFn` function matches documents in a single collection and returns a `SearchResult` based on the given
- * `terms`, `limit`, and `sort`.
- *
- * A fourth `inject` argument can be given to add user-defined sections to the query, allowing complex requirements
- * to be added around the core query pattern.
- */
- export type SearchFn<T extends Searchable, S extends Searchable = T> = (
- terms?: Terms<Document<DeepNonNullable<S>>>,
- limit?: Limit,
- sort?: Sort<Document<S>>[] | Sort<Document<S>>,
- inject?: Inject
- ) => Promise<SearchResult<T>>
- /**
- * A `SimpleSearchFn` function matches documents in a single collection and returns a `SearchResult` based on the given
- * `terms`, `limit`, and `sort`.
- *
- * This type can be implemented by user code as an alternative to `SearchFn` to prevent further injections.
- */
- export type SimpleSearchFn<T extends Searchable, S extends Searchable = T> = (
- terms?: Terms<Document<DeepNonNullable<S>>>,
- limit?: Limit,
- sort?: Sort<Document<S>>[] | Sort<Document<S>>,
- ) => Promise<SearchResult<T>>
- /**
- * Search results are a tuple of three values:
- * 1. The **total** number of matching documents in the searched collection, ignoring limit
- * 2. The documents matched within the searched collection, respecting limit
- * 3. The AQL query object for the latter (for debugging purposes)
- */
- export type SearchResult<T extends Searchable> = [number, Document<T>[], GeneratedAqlQuery]
- /**
- * Query sort order as a tuple of key and direction.
- *
- * The sort key can be specified as an AQL literal, in which case it will be used exactly as given.
- * This can be useful in conjunction with query injections.
- */
- export type Sort<T> = [AqlLiteral | keyof T, Direction]
- /** Format scalar or scalar array data for use in AQL. */
- export const formatData = <T>(data: T | T[]): string =>
- data instanceof Array ? `[${data.map(formatValue).join(',')}]` : formatValue(data)
- /** Format scalar data for use in AQL. */
- export const formatValue = <T>(data: T): string => {
- if (typeof data === 'string') return `"${data}"`
- if (data === null) return 'null'
- return `${data}`
- }
- /** Map of search operator properties to AQL equivalents. */
- export const operatorMap: Record<keyof Omit<Filter<unknown>, 'mode'>, string> = {
- eq: '==',
- gt: '>',
- gte: '>=',
- in: 'IN',
- like: 'LIKE',
- lt: '<',
- lte: '<=',
- neq: '!=',
- nin: 'NOT IN',
- nlike: 'NOT LIKE',
- nreg: '!~',
- reg: '=~'
- }
- /** Search operators. */
- export const operators = Object.keys(operatorMap)
- /**
- * Parse a search limit to a string AQL limit.
- *
- * Note: `LIMIT` is not prepended.
- */
- export const parseLimit = (l: Limit) => l.length > 1 ? `${l[0]}, ${l[1]}` : l[0]
- /**
- * Parse a search filter to a string of AQL filters.
- *
- * Note: `FILTER` is not prepended.
- */
- export const parseFilter = <T>(param: string, search: Filter<T>) => parseFilterOps(search)
- .map(([op, data]) => `${param} ${op} ${formatData(data)}`)
- .join(` ${search.mode || 'AND'} `)
- /** Parse search parameter object to FILTER statement(s). */
- const parseFilterOps = <T>(search: Filter<T>) =>
- (Object.keys(search) as (keyof typeof search)[]).map(key => {
- if (key === 'mode' || search[key] === undefined) return undefined
- if (operatorMap[key] === undefined) throw new Error('unrecognised search operator')
- return [operatorMap[key], search[key]]
- }).filter(Boolean) as [string, T | T[] | null][]
- /**
- * Parse query sort(s) to an array of string AQL sorts.
- *
- * Note: `SORT` is not prepended.
- */
- export const parseSort = <T>(s: Sort<T>[] | Sort<T>, parent: string): string[] => {
- if (s[0] instanceof Array) return (s as Sort<T>[]).map(ss => `${renderSortKey(ss, parent)} ${ss[1]}`)
- return [`${parent}.${String(s[0])} ${s[1]}`]
- }
- /**
- * Parse search terms to a flat array of search filters.
- * The `parent` argument refers to the current document, and is prefixed to each filter.
- */
- export const parseTerms = <T>(s: Terms<T>, parent: string) => (Object.keys(s) as (keyof T)[])
- .reduce((filters, param) => {
- const f = s[param]
- if (!f) return filters
- if (Object.keys(f).find(k => k !== 'mode' && !operators.includes(k))) {
- // object is nested
- filters.push(...parseTerms(f as Terms<typeof f>, `${parent}.${String(param)}`))
- }
- else {
- // object resembles a search parameter
- filters.push(parseFilter(`${parent}.${String(param)}`, f))
- }
- return filters
- }, <string[]>[])
- const renderSortKey = <T>([key]: Sort<T>, parent: string): string => {
- if (key instanceof Object) return key.toAQL()
- return `${parent}.${String(key)}`
- }
- /**
- * Build and execute a count query that matches documents in a single collection.
- * Returns the total number of matches.
- *
- * This example resembles the generated AQL query:
- *
- * ```aql
- * FOR {i} IN {c} {FILTER ...} COLLECT WITH COUNT INTO {n} RETURN {n}
- * ```
- */
- export const count = <T extends Searchable, S extends Searchable = T>(
- db: Database,
- c: DocumentCollection<T>,
- i = 'i',
- n = 'n'
- ): CountFn<T, S> => async (terms, inject) => {
- const filters = terms && parseTerms(terms, i)
- const filterStr = aql.literal(filters ? filters.map(f => `FILTER ${f}`).join(' ') : '')
- const l = { i: aql.literal(i), n: aql.literal(n) }
- const countQuery = aql`
- FOR ${l.i} IN ${c}
- ${inject?.beforeFilter && aql.literal(inject.beforeFilter)}
- ${filterStr}
- ${inject?.beforeSort && aql.literal(inject.beforeSort)}
- ${inject?.beforeLimit && aql.literal(inject.beforeLimit)}
- ${inject?.after && aql.literal(inject.after)}
- COLLECT WITH COUNT INTO ${l.n}
- RETURN ${l.n}
- `
- return await (await db.query(countQuery)).next()
- }
- /**
- * Build and execute a find query that returns the first matching document in a single collection.
- *
- * This example resembles the generated AQL query:
- *
- * ```aql
- * FOR {i} IN {collection} {FILTER ...} {SORT ...} LIMIT 1 RETURN {i}
- * ```
- */
- export const find = <T extends Searchable, S extends Searchable = T>(
- db: Database,
- c: DocumentCollection<T>,
- i = 'i'
- ): FindFn<T, S> => async (terms, sort = ['_key', 'ASC'], inject) => {
- const filters = terms && parseTerms(terms, 'i')
- const filterStr = aql.literal(filters ? filters.map(f => `FILTER ${f}`).join(' ') : '')
- const sortStr = aql.literal(sort ? `SORT ${parseSort(sort, 'i').join(', ')}` : '')
- const l = { i: aql.literal(i) }
- const query = aql`
- FOR ${l.i} IN ${c}
- ${inject?.beforeFilter && aql.literal(inject.beforeFilter)}
- ${filterStr}
- ${inject?.beforeSort && aql.literal(inject.beforeSort)}
- ${sortStr}
- ${inject?.beforeLimit && aql.literal(inject.beforeLimit)}
- ${inject?.after && aql.literal(inject.after)}
- LIMIT 1
- RETURN ${l.i}
- `
- const data = await (await db.query(query)).next()
- return data
- }
- /**
- * Build and execute a search query across a single collection.
- * Returns a `SearchResult` tuple containing the total number of matches (ignoring limit), all matching documents
- * (respecting limit), and the AQL query.
- *
- * This example resembles the generated AQL query:
- *
- * ```aql
- * FOR {i} IN {collection} {FILTER ...} {SORT ...} {LIMIT ...} RETURN {i}
- * ```
- */
- export const search = <T extends Searchable, S extends Searchable = T>(
- db: Database,
- c: DocumentCollection<T>,
- i = 'i',
- n = 'n'
- ): SearchFn<T, S> => async (terms, limit, sort = ['_rev', 'ASC'], inject) => {
- const filters = terms && parseTerms(terms, 'i')
- const filterStr = aql.literal(filters ? filters.map(f => `FILTER ${f}`).join(' ') : '')
- const limitStr = aql.literal(limit ? `LIMIT ${parseLimit(limit)}` : '')
- const sortStr = aql.literal(sort ? `SORT ${parseSort(sort, 'i').join(', ')}` : '')
- const l = { i: aql.literal(i), n: aql.literal(n) }
- let count = 0
- if (limit) {
- const countQuery = aql`
- FOR ${l.i} IN ${c}
- ${inject?.beforeFilter && aql.literal(inject.beforeFilter)}
- ${filterStr}
- ${inject?.beforeSort && aql.literal(inject.beforeSort)}
- ${inject?.beforeLimit && aql.literal(inject.beforeLimit)}
- ${inject?.after && aql.literal(inject.after)}
- COLLECT WITH COUNT INTO ${l.n}
- RETURN ${l.n}
- `
- count = await (await db.query(countQuery)).next()
- }
- const query = aql`
- FOR ${l.i} IN ${c}
- ${inject?.beforeFilter && aql.literal(inject.beforeFilter)}
- ${filterStr}
- ${inject?.beforeSort && aql.literal(inject.beforeSort)}
- ${sortStr}
- ${inject?.beforeLimit && aql.literal(inject.beforeLimit)}
- ${limitStr}
- ${inject?.after && aql.literal(inject.after)}
- RETURN ${l.i}
- `
- const data = await (await db.query(query)).all()
- if (data.length > count) count = data.length
- return [count, data, query]
- }
- export default {
- count,
- find,
- formatData,
- formatValue,
- operatorMap,
- operators,
- parseFilter,
- parseLimit,
- parseSort,
- parseTerms,
- search
- }
|