index.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import { Document } from 'arangojs/documents'
  2. import { DocumentCollection } from 'arangojs/collection'
  3. import { GeneratedAqlQuery } from 'arangojs/aql'
  4. import { Database, aql } from 'arangojs'
  5. /**
  6. * A `CountFn` function returns the number of documents in a single collection matching search `terms` given.
  7. *
  8. * `count()` provides the standard implementation.
  9. */
  10. export type CountFn<T extends Searchable, S extends Searchable = T> = (
  11. terms?: Terms<Document<DeepNonNullable<S>>>
  12. ) => Promise<number>
  13. /**
  14. * Recursively renders all of a complex object's properties required, non-null, and non-undefined.
  15. *
  16. * Note: this type recurses through objects only, not arrays.
  17. */
  18. export type DeepNonNullable<T> = NonNullable<T> extends object ? { [P in keyof T]-?: DeepNonNullable<T[P]> } : T
  19. /** Query sort direction. */
  20. export type Direction = 'ASC' | 'DESC'
  21. /**
  22. * Simple search filter type in which any number of conditions can be specified for a presumed parameter.
  23. *
  24. * Multiple operators can be used in combination.
  25. * By default they will be combined into an `AND` filter.
  26. * Specify `mode: "OR"` to switch to an `OR` filter.
  27. *
  28. * Note: `LIKE`, `NOT LIKE`, and regular expression operators are only available if `T` extends `string`.
  29. */
  30. export type Filter<T> = {
  31. mode?: 'AND' | 'OR'
  32. eq?: T | null
  33. gt?: T
  34. gte?: T
  35. in?: T[]
  36. like?: T & string
  37. lt?: T
  38. lte?: T
  39. neq?: T | null
  40. nin?: T[]
  41. nlike?: T & string
  42. nreg?: T & string
  43. reg?: T & string
  44. }
  45. /**
  46. * A `FindFn` function returns the first document in a single collection matching search `terms` given.
  47. * A `sort` can be specified to control the result.
  48. *
  49. * `find()` provides the standard implementation.
  50. */
  51. export type FindFn<T extends Searchable, S extends Searchable = T> = (
  52. terms?: Terms<Document<DeepNonNullable<S>>>,
  53. sort?: Sort<Document<T>>[] | Sort<Document<T>>
  54. ) => Promise<Document<T> | undefined>
  55. /**
  56. * Query limit.
  57. * Always a tuple, but the second value can be omitted.
  58. */
  59. export type Limit = [number, number?]
  60. /**
  61. * Searchable data type.
  62. * Essentially, any object is valid.
  63. */
  64. export type Searchable = Record<string, unknown>
  65. /**
  66. * Formulate search terms based on a complex data type.
  67. * Nested object types are preserved, while scalar properties are converted to `Filter` representations.
  68. */
  69. export type Terms<T> = {
  70. [P in keyof T]?: NonNullable<NonNullable<T[P]> extends object ? Terms<T[P]> : Filter<T[P]>>
  71. }
  72. /**
  73. * A `SearchFn` function matches documents in a single collection and returns a `SearchResult` based on the given
  74. * `terms`, `limit`, and `sort`.
  75. */
  76. export type SearchFn<T extends Searchable, S extends Searchable = T> = (
  77. terms?: Terms<Document<DeepNonNullable<S>>>,
  78. limit?: Limit,
  79. sort?: Sort<Document<T>>[] | Sort<Document<T>>
  80. ) => Promise<SearchResult<T>>
  81. /**
  82. * Search results are a tuple of three values:
  83. * 1. The **total** number of matching documents in the searched collection, ignoring limit
  84. * 2. The documents matched within the searched collection, respecting limit
  85. * 3. The AQL query object for the latter (for debugging purposes)
  86. */
  87. export type SearchResult<T extends Searchable> = [number, Document<T>[], GeneratedAqlQuery]
  88. /** Query sort order. */
  89. export type Sort<T> = [keyof T, Direction]
  90. /** Format scalar or scalar array data for use in AQL. */
  91. export const formatData = <T>(data: T | T[]): string =>
  92. data instanceof Array ? `[${data.map(formatValue).join(',')}]` : formatValue(data)
  93. /** Format scalar data for use in AQL. */
  94. export const formatValue = <T>(data: T): string => {
  95. if (typeof data === 'string') return `"${data}"`
  96. if (data === null) return 'null'
  97. return `${data}`
  98. }
  99. /** Map of search operator properties to AQL equivalents. */
  100. export const operatorMap: Record<keyof Omit<Filter<unknown>, 'mode'>, string> = {
  101. eq: '==',
  102. gt: '>',
  103. gte: '>=',
  104. in: 'IN',
  105. like: 'LIKE',
  106. lt: '<',
  107. lte: '<=',
  108. neq: '!=',
  109. nin: 'NOT IN',
  110. nlike: 'NOT LIKE',
  111. nreg: '!~',
  112. reg: '=~'
  113. }
  114. /** Search operators. */
  115. export const operators = Object.keys(operatorMap)
  116. /**
  117. * Parse a search limit to a string AQL limit.
  118. *
  119. * Note: `LIMIT` is not prepended.
  120. */
  121. export const parseLimit = (l: Limit) => l.length > 1 ? `${l[0]}, ${l[1]}` : l[0]
  122. /**
  123. * Parse a search filter to a string of AQL filters.
  124. *
  125. * Note: `FILTER` is not prepended.
  126. */
  127. export const parseFilter = <T>(param: string, search: Filter<T>) => parseFilterOps(search)
  128. .map(([op, data]) => `${param} ${op} ${formatData(data)}`)
  129. .join(` ${search.mode || 'AND'} `)
  130. /** Parse search parameter object to FILTER statement(s). */
  131. const parseFilterOps = <T>(search: Filter<T>) =>
  132. (Object.keys(search) as (keyof typeof search)[]).map(key => {
  133. if (key === 'mode' || search[key] === undefined) return undefined
  134. if (operatorMap[key] === undefined) throw new Error('unrecognised search operator')
  135. return [operatorMap[key], search[key]]
  136. }).filter(Boolean) as [string, T | T[] | null][]
  137. /**
  138. * Parse query sort(s) to an array of string AQL sorts.
  139. *
  140. * Note: `SORT` is not prepended.
  141. */
  142. export const parseSort = <T>(s: Sort<T>[] | Sort<T>, parent: string): string[] => {
  143. if (s[0] instanceof Array) return (s as Sort<T>[]).map(ss => `${parent}.${String(ss[0])} ${ss[1]}`)
  144. return [`${parent}.${String(s[0])} ${s[1]}`]
  145. }
  146. /**
  147. * Parse search terms to a flat array of search filters.
  148. * The `parent` argument refers to the current document, and is prefixed to each filter.
  149. */
  150. export const parseTerms = <T>(s: Terms<T>, parent: string) => (Object.keys(s) as (keyof T)[])
  151. .reduce((filters, param) => {
  152. const f = s[param]
  153. if (!f) return filters
  154. if (Object.keys(f).find(k => k !== 'mode' && !operators.includes(k))) {
  155. // object is nested
  156. filters.push(...parseTerms(f as Terms<typeof f>, `${parent}.${String(param)}`))
  157. }
  158. else {
  159. // object resembles a search parameter
  160. filters.push(parseFilter(`${parent}.${String(param)}`, f))
  161. }
  162. return filters
  163. }, <string[]>[])
  164. /**
  165. * Build and execute a count query that matches documents in a single collection.
  166. * Returns the total number of matches.
  167. *
  168. * This example resembles the generated AQL query:
  169. *
  170. * ```aql
  171. * FOR {i} IN {c} {FILTER ...} COLLECT WITH COUNT INTO {n} RETURN {n}
  172. * ```
  173. */
  174. export const count = <T extends Searchable, S extends Searchable = T>(
  175. db: Database,
  176. c: DocumentCollection<T>,
  177. i = 'i',
  178. n = 'n'
  179. ): CountFn<T, S> => async (terms) => {
  180. const filters = terms && parseTerms(terms, i)
  181. const filterStr = aql.literal(filters ? filters.map(f => `FILTER ${f}`).join(' ') : '')
  182. const l = { i: aql.literal(i), n: aql.literal(n) }
  183. const countQuery = aql`
  184. FOR ${l.i} IN ${c}
  185. ${filterStr}
  186. COLLECT WITH COUNT INTO ${l.n}
  187. RETURN ${l.n}
  188. `
  189. return await (await db.query(countQuery)).next()
  190. }
  191. /**
  192. * Build and execute a find query that returns the first matching document in a single collection.
  193. *
  194. * This example resembles the generated AQL query:
  195. *
  196. * ```aql
  197. * FOR {i} IN {collection} {FILTER ...} {SORT ...} LIMIT 1 RETURN {i}
  198. * ```
  199. */
  200. export const find = <T extends Searchable, S extends Searchable = T>(
  201. db: Database,
  202. c: DocumentCollection<T>,
  203. i = 'i'
  204. ): FindFn<T, S> => async (terms, sort = ['_key', 'ASC']) => {
  205. const filters = terms && parseTerms(terms, 'i')
  206. const filterStr = aql.literal(filters ? filters.map(f => `FILTER ${f}`).join(' ') : '')
  207. const sortStr = aql.literal(sort ? `SORT ${parseSort(sort, 'i').join(', ')}` : '')
  208. const l = { i: aql.literal(i) }
  209. const query = aql`
  210. FOR ${l.i} IN ${c}
  211. ${filterStr}
  212. ${sortStr}
  213. LIMIT 1
  214. RETURN ${l.i}
  215. `
  216. const data = await (await db.query(query)).next()
  217. return data
  218. }
  219. /**
  220. * Build and execute a search query across a single collection.
  221. * Returns a `SearchResult` tuple containing the total number of matches (ignoring limit), all matching documents
  222. * (respecting limit), and the AQL query.
  223. *
  224. * This example resembles the generated AQL query:
  225. *
  226. * ```aql
  227. * FOR {i} IN {collection} {FILTER ...} {SORT ...} {LIMIT ...} RETURN {i}
  228. * ```
  229. */
  230. export const search = <T extends Searchable, S extends Searchable = T>(
  231. db: Database,
  232. c: DocumentCollection<T>,
  233. i = 'i',
  234. n = 'n'
  235. ): SearchFn<T, S> => async (terms, limit, sort = ['_rev', 'ASC']) => {
  236. const filters = terms && parseTerms(terms, 'i')
  237. const filterStr = aql.literal(filters ? filters.map(f => `FILTER ${f}`).join(' ') : '')
  238. const limitStr = aql.literal(limit ? `LIMIT ${parseLimit(limit)}` : '')
  239. const sortStr = aql.literal(sort ? `SORT ${parseSort(sort, 'i').join(', ')}` : '')
  240. const l = { i: aql.literal(i), n: aql.literal(n) }
  241. let count = 0
  242. if (limit) {
  243. const countQuery = aql`
  244. FOR ${l.i} IN ${c}
  245. ${filterStr}
  246. COLLECT WITH COUNT INTO ${l.n}
  247. RETURN ${l.n}
  248. `
  249. count = await (await db.query(countQuery)).next()
  250. }
  251. const query = aql`
  252. FOR ${l.i} IN ${c}
  253. ${filterStr}
  254. ${sortStr}
  255. ${limitStr}
  256. RETURN ${l.i}
  257. `
  258. const data = await (await db.query(query)).all()
  259. if (data.length > count) count = data.length
  260. return [count, data, query]
  261. }
  262. export default {
  263. count,
  264. find,
  265. formatData,
  266. formatValue,
  267. operatorMap,
  268. operators,
  269. parseFilter,
  270. parseLimit,
  271. parseSort,
  272. parseTerms,
  273. search
  274. }