UNPKG

js-node-arango

Version:
340 lines (278 loc) 9.98 kB
import { AqlQuery } from 'arangojs/aql'; import { ArangoDocumentQueryOptions, ArangoEdgeQueryOptions, Filter, FilterObj, Primitive, } from '../types/arango-query-options.interface'; export type BindVars = Record<string, Primitive | Primitive[]>; export class ArangoBaseQueryBuilder { protected bindVars: BindVars = {}; // eslint-disable-next-line no-use-before-define protected additionalQueries: Map<string, ArangoBaseQueryBuilder> = new Map(); protected withClause = ''; protected selector = ''; protected filter = ''; protected sort = ''; protected limit = ''; protected doc = 'doc'; protected returnClause = 'doc'; protected inlineQueries = ''; private _isRootQuery = true; private _tick = 0; // eslint-disable-next-line no-use-before-define private _parent: ArangoBaseQueryBuilder | undefined = undefined; constructor(protected options: ArangoDocumentQueryOptions | ArangoEdgeQueryOptions) {} public prepareQuery(): AqlQuery { this.buildEverything(); let query = ''; if (this.options.withCount) { query += `${this.withClause}LET total = FIRST(${this.selector}${this.filter}COLLECT WITH COUNT INTO length RETURN length) ` + `LET docs = (${this.selector}${this.filter}${this.sort}${this.limit}${this.inlineQueries}RETURN ${this.returnClause}) ` + 'RETURN { docs, total }'; } else { query += `${this.withClause}${this.selector}${this.filter}${this.sort}${this.limit}${this.inlineQueries}RETURN ${this.returnClause}`; } return { query, bindVars: this.bindVars }; } public setDocSelector(doc: string) { this.checkSafe(doc); this.doc = doc; return this; } public getDocSelector() { return this.doc; } public addAdditionalQuery(varName: string, q: ArangoBaseQueryBuilder) { this.checkSafe(varName); if (varName === this.doc) throw new Error('Additional query variable name cannot be same with doc selector'); q.setParentQuery(this); this.additionalQueries.set(varName, q); return this; } protected buildSelector() { throw new Error('Override this method'); } protected buildFilter() { if (this.options.filterBy) { const filters = this.extractFilters(this.options.filterBy.filter); this.filter = `FILTER ${filters.map((e) => e.query).join(' && ')} `; for (let filter of filters) { this.bindVars = { ...filter.bindVars, ...this.bindVars }; } } } protected buildSort() { if (this.options.sortBy) { const sortFieldKeyName = `sortField_${this.getNextGlobalTick()}`; const sortOrderKeyName = `sortOrder_${this.getNextGlobalTick()}`; this.sort = `SORT ${this.doc}.@${sortFieldKeyName} @${sortOrderKeyName} `; this.bindVars[sortFieldKeyName] = this.options.sortBy.field.split('.'); this.bindVars[sortOrderKeyName] = this.options.sortBy.order; } } protected buildLimit() { if (this.options.limitBy) { const offsetKeyName = `limitByOffset_${this.getNextGlobalTick()}`; const limitKeyName = `limitByLimit_${this.getNextGlobalTick()}`; this.limit = `LIMIT @${offsetKeyName}, @${limitKeyName} `; this.bindVars[offsetKeyName] = this.options.limitBy.offset; this.bindVars[limitKeyName] = this.options.limitBy.limit; } } protected buildWithClause() { if (this._isRootQuery) { const targets = this.gatherRelatedCollectionsRecursively(); if (targets.length > 0) { this.withClause = `WITH ${this.handleCommaSeparated(targets, 'target')} `; } } } protected gatherRelatedCollectionsRecursively(): string[] { let targets: string[] = []; if ((this.options as ArangoEdgeQueryOptions).relatedCollections) { targets = this.concatArrays(targets, (this.options as ArangoEdgeQueryOptions).relatedCollections); } for (let elem of this.additionalQueries) { targets = this.concatArrays(targets, elem[1].gatherRelatedCollectionsRecursively()); } return targets; } protected buildReturnClause() { if (this.additionalQueries.size === 0) { this.returnClause = this.doc; return; } this.returnClause = `MERGE(${this.doc}, { ${Array.from(this.additionalQueries.keys()).join(', ')} })`; } protected buildAdditionalQueries() { if (this.additionalQueries.size === 0) { return; } const queries: string[] = []; for (let elem of this.additionalQueries) { const innerAqlQuery = elem[1].prepareQuery(); queries.push( `LET ${elem[0]} = ${elem[1].options.withCount || elem[1].options.limitBy?.limit === 1 ? 'FIRST' : ''}(${ innerAqlQuery.query }) `, ); this.bindVars = { ...innerAqlQuery.bindVars, ...this.bindVars }; } this.inlineQueries = queries.join(''); } protected buildEverything() { this.buildSelector(); this.buildFilter(); this.buildSort(); this.buildLimit(); this.buildWithClause(); this.buildReturnClause(); this.buildAdditionalQueries(); } private isFilterPrimitiveType(filter: any) { const checkPrimitive = function (input: any): boolean { return ( typeof input === 'string' || typeof input === 'number' || typeof input === 'boolean' || input === undefined || input === null ); }; if (!Array.isArray(filter)) { return checkPrimitive(filter); } return filter.every((elem) => checkPrimitive(elem)); } private isFilterProperFilterObject(filter: any) { return ( filter.hasOwnProperty('@value') && this.isFilterPrimitiveType(filter['@value']) && (filter.hasOwnProperty('@operator') || filter.hasOwnProperty('@docNameToFilter')) ); } /** * Flattens the provided filter object and returns an array with dot notation representation. * @example * ```js * // Given object: * const filter = { * gitRepoInfo: { * webhook: 12, * owner: { * name: 'Tospaa', * } * }, * state: 'InTest', * } * // Expected output: * [ * { * query: 'doc.gitRepoInfo.webhook == \@docgitRepoInfowebhook', * vars: { * bindVarName: 'docgitRepoInfowebhook', * value: 12 * } * }, * { * query: 'doc.gitRepoInfo.owner.name == \@docgitRepoInfoownername', * vars: { * bindVarName: 'docgitRepoInfoownername', * value: 'Tospaa' * } * }, * { * query: 'doc.gitRepoInfo.state == \@docgitRepoInfostate', * vars: { * bindVarName: 'docgitRepoInfostate', * value: 'InTest' * } * } * ] * ``` * @param filter Object provided to be flattened. * @param prevKey For recursive use only * @returns An array of filters */ protected extractFilters(filter: Filter, prevKey?: string): AqlQuery[] { prevKey ??= ''; const filters: AqlQuery[] = []; for (let key in filter) { if (this.isFilterPrimitiveType(filter[key]) || this.isFilterProperFilterObject(filter[key])) { const objectKey = `${prevKey}${key}`; const docPath = `path_${this.getNextGlobalTick()}`; const bindVarName = `filter_${this.getNextGlobalTick()}`; const query = `${(filter[key] as FilterObj)['@docNameToFilter'] ?? this.doc}.@${docPath} ${ (filter[key] as FilterObj)['@operator'] ?? '==' } @${bindVarName}`; const bindVars: BindVars = { [docPath]: objectKey.split('.'), [bindVarName]: (filter[key] as FilterObj)['@value'] ?? (filter[key] as Primitive | Primitive[]), }; filters.push({ query, bindVars }); continue; } prevKey += `${key}.`; filters.push(...this.extractFilters(filter[key] as Filter, prevKey)); } return filters; } protected handleCommaSeparated(values: string[], keyNameInput: string) { this.checkSafe(keyNameInput); if (values.length === 0) return ''; let statement = ''; for (let i = 0; i < values.length; i++) { const keyName = `${keyNameInput}_${this.getNextGlobalTick()}`; statement += `@@${keyName}`; if (i + 1 !== values.length) statement += ', '; this.bindVars[`@${keyName}`] = values[i]; } return statement; } protected checkSafe(phrase: string) { if (!/^[A-Za-z][A-Za-z0-9\-_]*$/.test(phrase)) throw new Error('This phrase does not seem safe and cannot be used in the query: ' + phrase); } protected concatArrays(array1: string[], array2: string | string[]): string[] { if (Array.isArray(array2)) { return array1.concat(array2); } array1.push(array2); return array1; } protected isRootQuery(): boolean { return this._isRootQuery; } protected setParentQuery(parent: ArangoBaseQueryBuilder): void { this._isRootQuery = false; this._parent = parent; } protected getParentQuery(): ArangoBaseQueryBuilder | undefined { return this._parent; } protected getTopmostParentQuery(): ArangoBaseQueryBuilder { if (this._parent === undefined) return this; if (this._parent.isRootQuery()) return this._parent; let parent = this._parent; while (true) { const localParent = parent.getParentQuery(); if (!localParent) { return parent; } if (localParent.isRootQuery()) { return localParent; } parent = localParent; } } protected getNextTick(): number { this._tick += 1; return this._tick; } protected getNextGlobalTick(): number { return this.getTopmostParentQuery().getNextTick(); } }