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 }