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 = ( terms?: Terms>>, inject?: Inject ) => Promise /** * 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 = NonNullable extends object ? { [P in keyof T]-?: DeepNonNullable } : 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 = { 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 = ( terms?: Terms>>, sort?: Sort>[] | Sort>, inject?: Inject ) => Promise | 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 /** * 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 = { [P in keyof T]?: NonNullable extends object ? Terms : Filter> } /** * 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 = ( terms?: Terms>>, limit?: Limit, sort?: Sort>[] | Sort>, inject?: Inject ) => Promise> /** * 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 = ( terms?: Terms>>, limit?: Limit, sort?: Sort>[] | Sort>, ) => Promise> /** * 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 = [number, Document[], 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 = [AqlLiteral | keyof T, Direction] /** Format scalar or scalar array data for use in AQL. */ export const formatData = (data: T | T[]): string => data instanceof Array ? `[${data.map(formatValue).join(',')}]` : formatValue(data) /** Format scalar data for use in AQL. */ export const formatValue = (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, '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 = (param: string, search: Filter) => parseFilterOps(search) .map(([op, data]) => `${param} ${op} ${formatData(data)}`) .join(` ${search.mode || 'AND'} `) /** Parse search parameter object to FILTER statement(s). */ const parseFilterOps = (search: Filter) => (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 = (s: Sort[] | Sort, parent: string): string[] => { if (s[0] instanceof Array) return (s as Sort[]).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 = (s: Terms, 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, `${parent}.${String(param)}`)) } else { // object resembles a search parameter filters.push(parseFilter(`${parent}.${String(param)}`, f)) } return filters }, []) const renderSortKey = ([key]: Sort, 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 = ( db: Database, c: DocumentCollection, i = 'i', n = 'n' ): CountFn => 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 = ( db: Database, c: DocumentCollection, i = 'i' ): FindFn => 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 = ( db: Database, c: DocumentCollection, i = 'i', n = 'n' ): SearchFn => 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 }