Explorar o código

initial part-tested edition

Aneurin Barker Snook %!s(int64=2) %!d(string=hai) anos
pai
achega
e9586859bd
Modificáronse 9 ficheiros con 577 adicións e 5 borrados
  1. 11 0
      .editorconfig
  2. 2 0
      .eslintignore
  3. 116 0
      .eslintrc.json
  4. 3 0
      .gitignore
  5. 7 0
      docker-compose.yml
  6. 305 0
      lib/index.ts
  7. 20 5
      package.json
  8. 88 0
      tests/count.test.ts
  9. 25 0
      tsconfig.json

+ 11 - 0
.editorconfig

@@ -0,0 +1,11 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{js,json,ts,vue}]
+indent_style = space
+indent_size = 2

+ 2 - 0
.eslintignore

@@ -0,0 +1,2 @@
+/dist
+/node_modules

+ 116 - 0
.eslintrc.json

@@ -0,0 +1,116 @@
+{
+  "parser": "@typescript-eslint/parser",
+  "plugins": [
+    "@typescript-eslint"
+  ],
+  "extends": [
+    "eslint:recommended",
+    "plugin:@typescript-eslint/recommended"
+  ],
+  "rules": {
+    "@typescript-eslint/member-delimiter-style": [
+      "error",
+      {
+        "multiline": {
+          "delimiter": "none",
+          "requireLast": true
+        },
+        "singleline": {
+          "delimiter": "comma",
+          "requireLast": false
+        }
+      }
+    ],
+    "@typescript-eslint/type-annotation-spacing": [
+      "error",
+      {
+        "before": false,
+        "after": true,
+        "overrides": {
+          "arrow": {
+            "before": true,
+            "after": true
+          }
+        }
+      }
+    ],
+    "arrow-body-style": [
+      "error",
+      "as-needed"
+    ],
+    "arrow-spacing": "error",
+    "brace-style": [
+      "error",
+      "stroustrup"
+    ],
+    "comma-dangle": [
+      "error",
+      "never"
+    ],
+    "curly": [
+      "off"
+    ],
+    "eol-last": [
+      "error",
+      "always"
+    ],
+    "indent": [
+      "error",
+      2
+    ],
+    "jsx-quotes": [
+      "error",
+      "prefer-double"
+    ],
+    "line-comment-position": [
+      "error",
+      "above"
+    ],
+    "linebreak-style": [
+      "error",
+      "unix"
+    ],
+    "max-len": [
+      "warn",
+      {
+        "code": 120
+      }
+    ],
+    "no-array-constructor": "error",
+    "no-eval": "error",
+    "no-lonely-if": "error",
+    "no-multi-assign": "error",
+    "no-new-object": "error",
+    "no-tabs": "error",
+    "no-trailing-spaces": "warn",
+    "no-unreachable": "error",
+    "no-var": "error",
+    "nonblock-statement-body-position": "error",
+    "one-var": [
+      "error",
+      "never"
+    ],
+    "prefer-arrow-callback": "error",
+    "prefer-const": "warn",
+    "quotes": [
+      "error",
+      "single"
+    ],
+    "semi": [
+      "error",
+      "never"
+    ],
+    "sort-imports": [
+      "warn",
+      {
+        "memberSyntaxSortOrder": [
+          "none",
+          "all",
+          "single",
+          "multiple"
+        ]
+      }
+    ],
+    "sort-vars": "error"
+  }
+}

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+/dist/*.tsbuildinfo
+/package-lock.json
+node_modules

+ 7 - 0
docker-compose.yml

@@ -0,0 +1,7 @@
+services:
+  arangodb:
+    image: arangodb:latest
+    environment:
+      ARANGO_NO_AUTH: 1
+    ports:
+      - "8529:8529"

+ 305 - 0
lib/index.ts

@@ -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
+}

+ 20 - 5
package.json

@@ -2,11 +2,26 @@
   "name": "arangosearch",
   "version": "1.0.0",
   "description": "",
-  "main": "index.js",
+  "main": "dist/lib/index.js",
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "build": "tsc",
+    "test": "env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register 'tests/**/*.ts'"
   },
-  "keywords": [],
-  "author": "",
-  "license": "ISC"
+  "keywords": ["arango", "arangodb", "aql"],
+  "author": "Aneurin \"Anny\" Barker Snook <a@aneur.in>",
+  "license": "MIT",
+  "dependencies": {
+    "arangojs": "^7.8.0"
+  },
+  "devDependencies": {
+    "@types/chai": "^4.3.3",
+    "@types/mocha": "^10.0.0",
+    "@typescript-eslint/eslint-plugin": "^5.40.1",
+    "@typescript-eslint/parser": "^5.40.1",
+    "chai": "^4.3.6",
+    "eslint": "^8.25.0",
+    "mocha": "^10.1.0",
+    "ts-node": "^10.9.1",
+    "typescript": "^4.8.4"
+  }
 }

+ 88 - 0
tests/count.test.ts

@@ -0,0 +1,88 @@
+import { Database } from 'arangojs'
+import { DocumentMetadata } from 'arangojs/documents'
+import { expect } from 'chai'
+import lib from '../lib'
+
+const testData: Record<string, (Pick<DocumentMetadata, '_key'> & Record<string, unknown>)[]> = {
+  pets: [
+    { _key: 'greedo', name: 'Greedo', age: 5, species: 'cat' },
+    { _key: 'haribo', name: 'Haribo', age: 1.5, species: 'dog' },
+    { _key: 'iguana', name: 'Iguana', age: 3, species: 'dog' },
+    { _key: 'jerkins', name: 'Jerkins', age: 15, species: 'cat' },
+    { _key: 'kahlua', name: 'Kahlua', age: 0.5, species: 'hamster' },
+    { _key: 'lemonade', name: 'Lemonade', age: 9, species: 'dog' }
+  ],
+  staff: [
+    { _key: 'aaron', name: 'Aaron', age: 38 },
+    { _key: 'benedict', name: 'Benedict', age: 25 },
+    { _key: 'cheryl', name: 'Cheryl', age: 34 },
+    { _key: 'davide', name: 'Davide', age: 52 },
+    { _key: 'emma', name: 'Emma', age: 23 },
+    { _key: 'frank', name: 'Frank', age: 47 }
+  ]
+}
+
+const getDatabase = async () => {
+  let db = new Database({
+    url: 'arangodb://127.0.0.1:8529'
+  })
+  if (!await db.database('arangosearch').exists()) {
+    await db.createDatabase('arangosearch')
+  }
+  db = db.database('arangosearch')
+  await installTestData(db)
+  return db
+}
+
+const installTestData = async (db: Database) => {
+  for (const c in testData) {
+    const collection = db.collection(c)
+    if (!await collection.exists()) {
+      await db.createCollection(c)
+      await collection.saveAll(testData[c], { overwriteMode: 'ignore' })
+    }
+  }
+}
+
+describe('count()', () => {
+  for (const c in testData) {
+    describe(c, () => {
+      const expected = testData[c].length
+      it(`should match ${expected} documents`, async () => {
+        const db = await getDatabase()
+        const count = await lib.count(db, db.collection(c))()
+        expect(count).to.equal(expected)
+      })
+    })
+  }
+})
+
+describe('count({ age: { gt: 5 }})', () => {
+  for (const c in testData) {
+    describe(c, () => {
+      const expected = testData[c].filter(i => i.age && i.age > 5).length
+      it(`should match ${expected} documents`, async () => {
+        const db = await getDatabase()
+        const count = await lib.count(db, db.collection(c))({
+          age: { gt: 5 }
+        })
+        expect(count).to.equal(expected)
+      })
+    })
+  }
+})
+
+describe('count({ name: { eq: "Iguana" }})', () => {
+  for (const c in testData) {
+    describe(c, () => {
+      const expected = testData[c].filter(i => i.name === 'Iguana').length
+      it(`should match ${expected} documents`, async () => {
+        const db = await getDatabase()
+        const count = await lib.count(db, db.collection(c))({
+          name: { eq: 'Iguana' }
+        })
+        expect(count).to.equal(expected)
+      })
+    })
+  }
+})

+ 25 - 0
tsconfig.json

@@ -0,0 +1,25 @@
+{
+  "compilerOptions": {
+    "allowJs": true,
+    "allowSyntheticDefaultImports": true,
+    "composite": true,
+    "esModuleInterop": true,
+    "forceConsistentCasingInFileNames": true,
+    "isolatedModules": true,
+    "module": "commonjs",
+    "moduleResolution": "node",
+    "noImplicitAny": true,
+    "outDir": "dist",
+    "preserveConstEnums": true,
+    "removeComments": false,
+    "resolveJsonModule": true,
+    "skipLibCheck": true,
+    "strict": true
+  },
+  "include": [
+    "lib/**/*"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
+}