js-node-arango
Version:
NodeJS ArangoDB Provider
340 lines (278 loc) • 9.98 kB
text/typescript
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();
}
}