Archived
initial part-tested edition
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
/dist
|
||||
/node_modules
|
||||
+116
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/dist/*.tsbuildinfo
|
||||
/package-lock.json
|
||||
node_modules
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
arangodb:
|
||||
image: arangodb:latest
|
||||
environment:
|
||||
ARANGO_NO_AUTH: 1
|
||||
ports:
|
||||
- "8529:8529"
|
||||
+305
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user