1
0

index.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import { Document } from 'arangojs/documents'
  2. import { DocumentCollection } from 'arangojs/collection'
  3. import { AqlLiteral, 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. inject?: Inject
  13. ) => Promise<number>
  14. /**
  15. * Recursively renders all of a complex object's properties required, non-null, and non-undefined.
  16. *
  17. * Note: this type recurses through objects only, not arrays.
  18. */
  19. export type DeepNonNullable<T> = NonNullable<T> extends object ? { [P in keyof T]-?: DeepNonNullable<T[P]> } : T
  20. /** Query sort direction. */
  21. export type Direction = 'ASC' | 'DESC'
  22. /**
  23. * Simple search filter type in which any number of conditions can be specified for a presumed parameter.
  24. *
  25. * Multiple operators can be used in combination.
  26. * By default they will be combined into an `AND` filter.
  27. * Specify `mode: "OR"` to switch to an `OR` filter.
  28. *
  29. * Note: `LIKE`, `NOT LIKE`, and regular expression operators are only available if `T` extends `string`.
  30. */
  31. export type Filter<T> = {
  32. mode?: 'AND' | 'OR'
  33. eq?: T | null
  34. gt?: T
  35. gte?: T
  36. in?: T[]
  37. like?: T & string
  38. lt?: T
  39. lte?: T
  40. neq?: T | null
  41. nin?: T[]
  42. nlike?: T & string
  43. nreg?: T & string
  44. reg?: T & string
  45. }
  46. /**
  47. * A `FindFn` function returns the first document in a single collection matching search `terms` given.
  48. * A `sort` can be specified to control the result.
  49. *
  50. * `find()` provides the standard implementation.
  51. */
  52. export type FindFn<T extends Searchable, S extends Searchable = T> = (
  53. terms?: Terms<Document<DeepNonNullable<S>>>,
  54. sort?: Sort<Document<T>>[] | Sort<Document<T>>,
  55. inject?: Inject
  56. ) => Promise<Document<T> | undefined>
  57. /**
  58. * An `Inject` object allows modifying a search query with user-defined AQL literal expressions.
  59. * This can be useful for complex requirements, such as joining data or setting additional variables with `LET`.
  60. *
  61. * All injected strings are implicitly converted to AQL literals in the query, so should be manually escaped as needed.
  62. *
  63. * Note that while `CountFn` and `FindFn` do not use all the same parameters as `SearchFn`, all injections are still
  64. * supported so a single injection provider can be used for all query types.
  65. */
  66. export type Inject = {
  67. /** Injected before FILTER statements. */
  68. beforeFilter?: string
  69. /** Injected after FILTER statements, before SORT statements. */
  70. beforeSort?: string
  71. /** Injected after SORT statements, before LIMIT statement. */
  72. beforeLimit?: string
  73. /** Injected after all other statements. */
  74. after?: string
  75. }
  76. /**
  77. * Query limit.
  78. * Always a tuple, but the second value can be omitted.
  79. */
  80. export type Limit = [number, number?]
  81. /**
  82. * Searchable data type.
  83. * Essentially, any object is valid.
  84. */
  85. export type Searchable = Record<string, unknown>
  86. /**
  87. * Formulate search terms based on a complex data type.
  88. * Nested object types are preserved, while scalar properties are converted to `Filter` representations.
  89. */
  90. export type Terms<T> = {
  91. [P in keyof T]?: NonNullable<NonNullable<T[P]> extends object ? Terms<T[P]> : Filter<T[P]>>
  92. }
  93. /**
  94. * A `SearchFn` function matches documents in a single collection and returns a `SearchResult` based on the given
  95. * `terms`, `limit`, and `sort`.
  96. *
  97. * A fourth `inject` argument can be given to add user-defined sections to the query, allowing complex requirements
  98. * to be added around the core query pattern.
  99. */
  100. export type SearchFn<T extends Searchable, S extends Searchable = T> = (
  101. terms?: Terms<Document<DeepNonNullable<S>>>,
  102. limit?: Limit,
  103. sort?: Sort<Document<S>>[] | Sort<Document<S>>,
  104. inject?: Inject
  105. ) => Promise<SearchResult<T>>
  106. /**
  107. * A `SimpleSearchFn` function matches documents in a single collection and returns a `SearchResult` based on the given
  108. * `terms`, `limit`, and `sort`.
  109. *
  110. * This type can be implemented by user code as an alternative to `SearchFn` to prevent further injections.
  111. */
  112. export type SimpleSearchFn<T extends Searchable, S extends Searchable = T> = (
  113. terms?: Terms<Document<DeepNonNullable<S>>>,
  114. limit?: Limit,
  115. sort?: Sort<Document<S>>[] | Sort<Document<S>>,
  116. ) => Promise<SearchResult<T>>
  117. /**
  118. * Search results are a tuple of three values:
  119. * 1. The **total** number of matching documents in the searched collection, ignoring limit
  120. * 2. The documents matched within the searched collection, respecting limit
  121. * 3. The AQL query object for the latter (for debugging purposes)
  122. */
  123. export type SearchResult<T extends Searchable> = [number, Document<T>[], GeneratedAqlQuery]
  124. /**
  125. * Query sort order as a tuple of key and direction.
  126. *
  127. * The sort key can be specified as an AQL literal, in which case it will be used exactly as given.
  128. * This can be useful in conjunction with query injections.
  129. */
  130. export type Sort<T> = [AqlLiteral | keyof T, Direction]
  131. /** Format scalar or scalar array data for use in AQL. */
  132. export const formatData = <T>(data: T | T[]): string =>
  133. data instanceof Array ? `[${data.map(formatValue).join(',')}]` : formatValue(data)
  134. /** Format scalar data for use in AQL. */
  135. export const formatValue = <T>(data: T): string => {
  136. if (typeof data === 'string') return `"${data}"`
  137. if (data === null) return 'null'
  138. return `${data}`
  139. }
  140. /** Map of search operator properties to AQL equivalents. */
  141. export const operatorMap: Record<keyof Omit<Filter<unknown>, 'mode'>, string> = {
  142. eq: '==',
  143. gt: '>',
  144. gte: '>=',
  145. in: 'IN',
  146. like: 'LIKE',
  147. lt: '<',
  148. lte: '<=',
  149. neq: '!=',
  150. nin: 'NOT IN',
  151. nlike: 'NOT LIKE',
  152. nreg: '!~',
  153. reg: '=~'
  154. }
  155. /** Search operators. */
  156. export const operators = Object.keys(operatorMap)
  157. /**
  158. * Parse a search limit to a string AQL limit.
  159. *
  160. * Note: `LIMIT` is not prepended.
  161. */
  162. export const parseLimit = (l: Limit) => l.length > 1 ? `${l[0]}, ${l[1]}` : l[0]
  163. /**
  164. * Parse a search filter to a string of AQL filters.
  165. *
  166. * Note: `FILTER` is not prepended.
  167. */
  168. export const parseFilter = <T>(param: string, search: Filter<T>) => parseFilterOps(search)
  169. .map(([op, data]) => `${param} ${op} ${formatData(data)}`)
  170. .join(` ${search.mode || 'AND'} `)
  171. /** Parse search parameter object to FILTER statement(s). */
  172. const parseFilterOps = <T>(search: Filter<T>) =>
  173. (Object.keys(search) as (keyof typeof search)[]).map(key => {
  174. if (key === 'mode' || search[key] === undefined) return undefined
  175. if (operatorMap[key] === undefined) throw new Error('unrecognised search operator')
  176. return [operatorMap[key], search[key]]
  177. }).filter(Boolean) as [string, T | T[] | null][]
  178. /**
  179. * Parse query sort(s) to an array of string AQL sorts.
  180. *
  181. * Note: `SORT` is not prepended.
  182. */
  183. export const parseSort = <T>(s: Sort<T>[] | Sort<T>, parent: string): string[] => {
  184. if (s[0] instanceof Array) return (s as Sort<T>[]).map(ss => `${renderSortKey(ss, parent)} ${ss[1]}`)
  185. return [`${parent}.${String(s[0])} ${s[1]}`]
  186. }
  187. /**
  188. * Parse search terms to a flat array of search filters.
  189. * The `parent` argument refers to the current document, and is prefixed to each filter.
  190. */
  191. export const parseTerms = <T>(s: Terms<T>, parent: string) => (Object.keys(s) as (keyof T)[])
  192. .reduce((filters, param) => {
  193. const f = s[param]
  194. if (!f) return filters
  195. if (Object.keys(f).find(k => k !== 'mode' && !operators.includes(k))) {
  196. // object is nested
  197. filters.push(...parseTerms(f as Terms<typeof f>, `${parent}.${String(param)}`))
  198. }
  199. else {
  200. // object resembles a search parameter
  201. filters.push(parseFilter(`${parent}.${String(param)}`, f))
  202. }
  203. return filters
  204. }, <string[]>[])
  205. const renderSortKey = <T>([key]: Sort<T>, parent: string): string => {
  206. if (key instanceof Object) return key.toAQL()
  207. return `${parent}.${String(key)}`
  208. }
  209. /**
  210. * Build and execute a count query that matches documents in a single collection.
  211. * Returns the total number of matches.
  212. *
  213. * This example resembles the generated AQL query:
  214. *
  215. * ```aql
  216. * FOR {i} IN {c} {FILTER ...} COLLECT WITH COUNT INTO {n} RETURN {n}
  217. * ```
  218. */
  219. export const count = <T extends Searchable, S extends Searchable = T>(
  220. db: Database,
  221. c: DocumentCollection<T>,
  222. i = 'i',
  223. n = 'n'
  224. ): CountFn<T, S> => async (terms, inject) => {
  225. const filters = terms && parseTerms(terms, i)
  226. const filterStr = aql.literal(filters ? filters.map(f => `FILTER ${f}`).join(' ') : '')
  227. const l = { i: aql.literal(i), n: aql.literal(n) }
  228. const countQuery = aql`
  229. FOR ${l.i} IN ${c}
  230. ${inject?.beforeFilter && aql.literal(inject.beforeFilter)}
  231. ${filterStr}
  232. ${inject?.beforeSort && aql.literal(inject.beforeSort)}
  233. ${inject?.beforeLimit && aql.literal(inject.beforeLimit)}
  234. ${inject?.after && aql.literal(inject.after)}
  235. COLLECT WITH COUNT INTO ${l.n}
  236. RETURN ${l.n}
  237. `
  238. return await (await db.query(countQuery)).next()
  239. }
  240. /**
  241. * Build and execute a find query that returns the first matching document in a single collection.
  242. *
  243. * This example resembles the generated AQL query:
  244. *
  245. * ```aql
  246. * FOR {i} IN {collection} {FILTER ...} {SORT ...} LIMIT 1 RETURN {i}
  247. * ```
  248. */
  249. export const find = <T extends Searchable, S extends Searchable = T>(
  250. db: Database,
  251. c: DocumentCollection<T>,
  252. i = 'i'
  253. ): FindFn<T, S> => async (terms, sort = ['_key', 'ASC'], inject) => {
  254. const filters = terms && parseTerms(terms, 'i')
  255. const filterStr = aql.literal(filters ? filters.map(f => `FILTER ${f}`).join(' ') : '')
  256. const sortStr = aql.literal(sort ? `SORT ${parseSort(sort, 'i').join(', ')}` : '')
  257. const l = { i: aql.literal(i) }
  258. const query = aql`
  259. FOR ${l.i} IN ${c}
  260. ${inject?.beforeFilter && aql.literal(inject.beforeFilter)}
  261. ${filterStr}
  262. ${inject?.beforeSort && aql.literal(inject.beforeSort)}
  263. ${sortStr}
  264. ${inject?.beforeLimit && aql.literal(inject.beforeLimit)}
  265. ${inject?.after && aql.literal(inject.after)}
  266. LIMIT 1
  267. RETURN ${l.i}
  268. `
  269. const data = await (await db.query(query)).next()
  270. return data
  271. }
  272. /**
  273. * Build and execute a search query across a single collection.
  274. * Returns a `SearchResult` tuple containing the total number of matches (ignoring limit), all matching documents
  275. * (respecting limit), and the AQL query.
  276. *
  277. * This example resembles the generated AQL query:
  278. *
  279. * ```aql
  280. * FOR {i} IN {collection} {FILTER ...} {SORT ...} {LIMIT ...} RETURN {i}
  281. * ```
  282. */
  283. export const search = <T extends Searchable, S extends Searchable = T>(
  284. db: Database,
  285. c: DocumentCollection<T>,
  286. i = 'i',
  287. n = 'n'
  288. ): SearchFn<T, S> => async (terms, limit, sort = ['_rev', 'ASC'], inject) => {
  289. const filters = terms && parseTerms(terms, 'i')
  290. const filterStr = aql.literal(filters ? filters.map(f => `FILTER ${f}`).join(' ') : '')
  291. const limitStr = aql.literal(limit ? `LIMIT ${parseLimit(limit)}` : '')
  292. const sortStr = aql.literal(sort ? `SORT ${parseSort(sort, 'i').join(', ')}` : '')
  293. const l = { i: aql.literal(i), n: aql.literal(n) }
  294. let count = 0
  295. if (limit) {
  296. const countQuery = aql`
  297. FOR ${l.i} IN ${c}
  298. ${inject?.beforeFilter && aql.literal(inject.beforeFilter)}
  299. ${filterStr}
  300. ${inject?.beforeSort && aql.literal(inject.beforeSort)}
  301. ${inject?.beforeLimit && aql.literal(inject.beforeLimit)}
  302. ${inject?.after && aql.literal(inject.after)}
  303. COLLECT WITH COUNT INTO ${l.n}
  304. RETURN ${l.n}
  305. `
  306. count = await (await db.query(countQuery)).next()
  307. }
  308. const query = aql`
  309. FOR ${l.i} IN ${c}
  310. ${inject?.beforeFilter && aql.literal(inject.beforeFilter)}
  311. ${filterStr}
  312. ${inject?.beforeSort && aql.literal(inject.beforeSort)}
  313. ${sortStr}
  314. ${inject?.beforeLimit && aql.literal(inject.beforeLimit)}
  315. ${limitStr}
  316. ${inject?.after && aql.literal(inject.after)}
  317. RETURN ${l.i}
  318. `
  319. const data = await (await db.query(query)).all()
  320. if (data.length > count) count = data.length
  321. return [count, data, query]
  322. }
  323. export default {
  324. count,
  325. find,
  326. formatData,
  327. formatValue,
  328. operatorMap,
  329. operators,
  330. parseFilter,
  331. parseLimit,
  332. parseSort,
  333. parseTerms,
  334. search
  335. }