|
@@ -0,0 +1,305 @@
|
|
|
|
+import { Document } from 'arangojs/documents'
|
|
|
|
+import { DocumentCollection } from 'arangojs/collection'
|
|
|
|
+import { 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>>>
|
|
|
|
+) => 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>>
|
|
|
|
+) => Promise<Document<T> | undefined>
|
|
|
|
+
|
|
|
|
+/**
|
|
|
|
+ * 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`.
|
|
|
|
+ */
|
|
|
|
+export type SearchFn<T extends Searchable, S extends Searchable = T> = (
|
|
|
|
+ terms?: Terms<Document<DeepNonNullable<S>>>,
|
|
|
|
+ limit?: Limit,
|
|
|
|
+ sort?: Sort<Document<T>>[] | Sort<Document<T>>
|
|
|
|
+) => 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. */
|
|
|
|
+export type Sort<T> = [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 => `${parent}.${String(ss[0])} ${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[]>[])
|
|
|
|
+
|
|
|
|
+/**
|
|
|
|
+ * 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) => {
|
|
|
|
+ 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}
|
|
|
|
+ ${filterStr}
|
|
|
|
+ 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']) => {
|
|
|
|
+ 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}
|
|
|
|
+ ${filterStr}
|
|
|
|
+ ${sortStr}
|
|
|
|
+ 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']) => {
|
|
|
|
+ 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}
|
|
|
|
+ ${filterStr}
|
|
|
|
+ COLLECT WITH COUNT INTO ${l.n}
|
|
|
|
+ RETURN ${l.n}
|
|
|
|
+ `
|
|
|
|
+ count = await (await db.query(countQuery)).next()
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const query = aql`
|
|
|
|
+ FOR ${l.i} IN ${c}
|
|
|
|
+ ${filterStr}
|
|
|
|
+ ${sortStr}
|
|
|
|
+ ${limitStr}
|
|
|
|
+ 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
|
|
|
|
+}
|