From e9586859bdc364d6640af0e134bafa2ea3580956 Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Tue, 18 Oct 2022 19:24:44 +0100 Subject: [PATCH] initial part-tested edition --- .editorconfig | 11 ++ .eslintignore | 2 + .eslintrc.json | 116 +++++++++++++++++ .gitignore | 3 + docker-compose.yml | 7 + lib/index.ts | 305 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 25 +++- tests/count.test.ts | 88 +++++++++++++ tsconfig.json | 25 ++++ 9 files changed, 577 insertions(+), 5 deletions(-) create mode 100644 .editorconfig create mode 100644 .eslintignore create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 lib/index.ts create mode 100644 tests/count.test.ts create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..bea9850 --- /dev/null +++ b/.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 diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..c925c21 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..f716ee5 --- /dev/null +++ b/.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" + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f71131e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/dist/*.tsbuildinfo +/package-lock.json +node_modules diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..63e41b6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +services: + arangodb: + image: arangodb:latest + environment: + ARANGO_NO_AUTH: 1 + ports: + - "8529:8529" diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..b80cb04 --- /dev/null +++ b/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 = ( + terms?: Terms>> +) => 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> +) => Promise | 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 + +/** + * 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`. + */ +export type SearchFn = ( + 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. */ +export type Sort = [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 => `${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 = (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 + }, []) + +/** + * 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) => { + 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 = ( + db: Database, + c: DocumentCollection, + i = 'i' +): FindFn => 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 = ( + db: Database, + c: DocumentCollection, + i = 'i', + n = 'n' +): SearchFn => 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 +} diff --git a/package.json b/package.json index cba6bef..469cf67 100644 --- a/package.json +++ b/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 ", + "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" + } } diff --git a/tests/count.test.ts b/tests/count.test.ts new file mode 100644 index 0000000..4c6bb28 --- /dev/null +++ b/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 & Record)[]> = { + 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) + }) + }) + } +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7afd65d --- /dev/null +++ b/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" + ] +}