@restorecommerce/chassis-srv
Version:
Restore Commerce microservice chassis
553 lines • 24 kB
JavaScript
import { aql } from 'arangojs';
import { isNullish, isEmptyish, isString, clone, forEach } from 'remeda';
import { buildFilter, buildSorter, buildLimiter, buildReturn, sanitizeInputFields, query, sanitizeOutputFields, idToKey } from './common.js';
import { LoggedError, toArray } from './utils.js';
/**
* ArangoDB database provider.
*/
export class Arango {
db;
logger;
customQueries = new Map();
collectionNameAnalyzerMap = new Map();
/**
*
* @param {Object} conn Arangojs database connection.
*/
constructor(db, logger) {
this.db = db;
this.logger = logger;
}
/**
* Find documents based on filter.
*
* @param {String} collectionName Collection name
* @param {Object} filter Key, value Object
* @param {Object} options options.limit, options.offset
* @return {Promise<any>} Promise for list of found documents.
*/
async find(collectionName, filter, options) {
if (isNullish(collectionName)
|| !isString(collectionName)
|| isEmptyish(collectionName)) {
throw new LoggedError(this.logger, 'invalid or missing collection argument for find operation');
}
let filterQuery = filter ?? {};
const opts = options ?? {};
let filterResult;
const customFilters = new Array();
// checking if a custom query should be used
if (!isEmptyish(opts.customQueries)) {
for (const queryName of opts.customQueries) {
if (!this.customQueries.has(queryName)) {
throw new LoggedError(this.logger, `custom query ${query} not found`);
}
const customQuery = this.customQueries.get(queryName);
if (customQuery.type === 'query') {
// standalone query
const result = await query(this.db, collectionName, customQuery.code, opts.customArguments || {}); // Cursor object
return result.all(); // TODO: paginate
}
else {
// filter
customFilters.push(customQuery.code);
}
}
}
if (isNullish(filterQuery) || isEmptyish(filterQuery)) {
filterQuery = '';
}
else {
filterQuery = toArray(filterQuery);
filterResult = buildFilter(filterQuery);
filterQuery = `FILTER ${filterResult.q}`;
}
// if search options are set build search query
const searchQueries = new Array();
if (opts?.search?.search && this.collectionNameAnalyzerMap && this.collectionNameAnalyzerMap.get(collectionName)) {
const searchString = JSON.stringify(options.search.search);
const searchFields = (options?.search?.fields?.length > 0) ? options.search.fields : this.collectionNameAnalyzerMap.get(collectionName).fields;
const similarityThreshold = this.collectionNameAnalyzerMap.get(collectionName).similarityThreshold;
const viewName = this.collectionNameAnalyzerMap.get(collectionName).viewName;
const caseSensitive = options?.search?.case_sensitive;
const analyzerOptions = this.collectionNameAnalyzerMap.get(collectionName).analyzerOptions;
const analyzerName = JSON.stringify(analyzerOptions.flatMap((opt) => Object.entries(opt)).find(([k, v]) => caseSensitive ? v?.type === 'ngram' : v?.type === 'pipeline')[0]);
for (const field of searchFields) {
searchQueries.push(`NGRAM_MATCH(node.${field}, ${searchString}, ${similarityThreshold}, ${analyzerName})`);
}
// override collection name with view name
collectionName = viewName;
}
else if (opts?.search?.search) {
this.logger?.warn(`View and analyzer configuration data not set for ${collectionName} and hence ignoring search string`);
}
// override sortQuery (to rank based on score for frequency search match - term frequency–inverse document frequency algorithm TF-IDF)
const sortQuery = isEmptyish(searchQueries) ? buildSorter(opts) : 'SORT TFIDF(node) DESC';
const limitQuery = buildLimiter(opts);
const returnResult = buildReturn(opts);
const returnQuery = isEmptyish(returnResult?.q) ? 'RETURN node' : returnResult.q;
const queryStrings = ['FOR node IN @@collection'];
if (!isEmptyish(searchQueries)) {
queryStrings.push(`SEARCH ${searchQueries.join(' OR ')}`);
}
queryStrings.push(filterQuery);
queryStrings.push(...customFilters);
queryStrings.push(sortQuery, limitQuery, returnQuery);
const bindVars = {
'@collection': collectionName,
...filterResult?.bindVarsMap,
...returnResult?.bindVarsMap,
};
if (!isNullish(opts.limit)) {
bindVars.limit = opts.limit;
}
if (!isNullish(opts.offset)) {
bindVars.offset = opts.offset;
}
if (!isEmptyish(customFilters) && opts.customArguments) {
bindVars.customArguments = opts.customArguments;
}
const queryString = queryStrings.filter(s => !isNullish(s) && !isEmptyish(s)).join(' ');
try {
const res = !isEmptyish(searchQueries)
? await this.db.query(queryString, bindVars)
: await query(this.db, collectionName, queryString, bindVars);
const docs = await res.all(); // TODO: paginate
return docs.map(sanitizeOutputFields);
}
catch (error) {
const { message, stack } = error;
throw new LoggedError(this.logger, 'ArangoDB:', { message, stack, queryString });
}
}
/**
* Find documents by id (_key).
*
* @param {String} collection Collection name
* @param {String|array.String} ids A single ID or multiple IDs.
* @return {Promise<any>} A list of found documents.
*/
async findByID(collectionName, ids) {
if (isNullish(collectionName) || !isString(collectionName) ||
isEmptyish(collectionName)) {
throw new LoggedError(this.logger, 'invalid or missing collection argument for findByID operation');
}
if (isNullish(ids)) {
throw new LoggedError(this.logger, 'invalid or missing ids argument for findByID operation');
}
if (!Array.isArray(ids)) {
ids = [ids];
}
const filter = ids.map((id) => {
return { id };
});
const filterResult = buildFilter(filter);
const filterQuery = filterResult.q;
const varArgs = filterResult.bindVarsMap ?? {};
const queryString = `FOR node IN @ FILTER ${filterQuery} RETURN node`;
const bindVars = Object.assign({
'@collection': collectionName
}, varArgs);
const res = await query(this.db, collectionName, queryString, bindVars);
const docs = await res.all();
return docs.map(sanitizeOutputFields);
}
/**
* retreive the documents including the document handlers (_key, _id and _rev).
*
* @param {String} collectionName Collection name
* @param {any} collection Collection Object
* @param {ArangoDocument | ArangoDocument[]} documents list of documents
* @param {string[]} idsArray list of document ids
* @returns {Promise<ArangoDocument[]>} A list of documents including the document handlers
*/
async getDocumentHandlers(collectionName, collection, documents, idsArray) {
const ids = idsArray ?? toArray(documents)?.map(doc => doc.id);
const queryString = aql `FOR node IN ${collection} FILTER node.id IN ${ids} LIMIT ${ids.length} RETURN node`;
const res = await query(this.db, collectionName, queryString);
const docsWithSelector = await res.all();
return docsWithSelector;
}
/**
* Find documents by filter and updates them with document.
*
* @param {String} collection Collection name
* @param {Object} updateDocuments List of documents to update
*/
async update(collectionName, updateDocuments) {
const documents = clone(updateDocuments);
const updateDocsResponse = [];
if (isNullish(collectionName)
|| !isString(collectionName)
|| isEmptyish(collectionName)) {
throw new LoggedError(this.logger, 'invalid or missing collection argument for update operation');
}
if (isNullish(documents)) {
throw new LoggedError(this.logger, 'invalid or missing document argument for update operation');
}
const collection = this.db.collection(collectionName);
const collectionExists = await collection.exists();
if (!collectionExists) {
throw new LoggedError(this.logger, `Collection ${collectionName} does not exist for update operation`);
}
if (!Array.isArray(documents)) {
throw new LoggedError(this.logger, `Documents should be list for update operation`);
}
const docsWithHandlers = await this.getDocumentHandlers(collectionName, collection, documents);
// update _key for the input documents
for (const document of documents) {
let foundInDB = false;
for (const docWithHandler of docsWithHandlers) {
if (docWithHandler.id === document.id) {
foundInDB = true;
document._key = docWithHandler._key;
break;
}
}
if (!foundInDB) {
// if document is not found in DB use the id itself as _key
// this key will return an array in response since it does not exist
document._key = idToKey(document.id);
}
}
const updatedDocs = await collection.updateAll(documents, { returnNew: true });
for (const doc of updatedDocs) {
if ('new' in doc) {
updateDocsResponse.push(sanitizeOutputFields(doc?.new));
}
else {
updateDocsResponse.push(doc);
}
}
return updateDocsResponse;
}
/**
* Find each document based on it's key and update it.
* If the document does not exist it will be created.
*
* @param {String} collectionName Collection name
* @param {Object|Array.Object} documents
*/
async upsert(collectionName, documents) {
if (isNullish(collectionName)
|| !isString(collectionName)
|| isEmptyish(collectionName)) {
throw new LoggedError(this.logger, 'invalid or missing collection argument for upsert operation');
}
if (isNullish(documents)) {
throw new LoggedError(this.logger, 'invalid or missing documents argument for upsert operation');
}
let docs = clone(documents);
if (!Array.isArray(documents)) {
docs = [documents];
}
forEach(docs, (document, i) => {
docs[i] = sanitizeInputFields(document);
});
const upsertResponse = [];
const collection = this.db.collection(collectionName);
const collectionExists = await collection.exists();
if (!collectionExists) {
await collection.create().catch(error => {
const { code, message, details, stack } = error;
throw new LoggedError(this.logger, `Collection ${collectionName} not created!`, { code, message, details, stack });
});
}
let upsertedDocs = await collection.saveAll(docs, { returnNew: true, overwriteMode: 'update' });
if (!Array.isArray(upsertedDocs)) {
upsertedDocs = [upsertedDocs];
}
for (const doc of upsertedDocs) {
if ('new' in doc) {
upsertResponse.push(sanitizeOutputFields(doc.new));
}
else {
upsertResponse.push(doc);
}
}
return upsertResponse;
}
/**
* Delete all documents with provided identifiers ids.
*
* @param {String} collection Collection name
* @param {Object} ids list of document identifiers
* @return {Promise<any>} delete response
*/
async delete(collectionName, ids) {
if (isNullish(collectionName) ||
!isString(collectionName) || isEmptyish(collectionName)) {
throw new LoggedError(this.logger, 'invalid or missing collection argument');
}
if (isNullish(ids) || isEmptyish(ids)) {
throw new LoggedError(this.logger, 'invalid or missing document IDs argument for delete operation');
}
const collection = this.db.collection(collectionName);
const collectionExists = await collection.exists();
if (!collectionExists) {
throw new LoggedError(this.logger, `Collection ${collectionName} does not exist for delete operation`);
}
// retreive _key for the give ids
const docsWithHandlers = await this.getDocumentHandlers(collectionName, collection, undefined, ids);
for (const id of ids) {
// check if given id is present in docsWithHandlers
let foundDocInDB = false;
for (const doc of docsWithHandlers) {
if (doc.id === id) {
foundDocInDB = true;
break;
}
}
// if document is not found in DB use the id itself as _key
// this key will return an array in response since it does not exist
if (!foundDocInDB) {
docsWithHandlers.push({ _key: id });
}
}
const deleteHandlerIds = docsWithHandlers.map((doc) => doc._key);
return collection.removeAll(deleteHandlerIds);
}
/**
* Count all documents selected by filter.
*
* @param {String} collection Collection name
* @param {Object} filter
*/
async count(collectionName, filter) {
if (isNullish(collectionName) ||
!isString(collectionName) || isEmptyish(collectionName)) {
throw new LoggedError(this.logger, 'invalid or missing collection argument for count operation');
}
let filterQuery = filter || {};
let filterResult;
if (!Array.isArray(filterQuery)) {
filterQuery = [filterQuery];
}
if (isEmptyish(filterQuery[0])) {
filterQuery = true;
}
else {
filterResult = buildFilter(filterQuery);
filterQuery = filterResult.q;
}
let varArgs = {};
if (filterResult && filterResult.bindVarsMap) {
varArgs = filterResult.bindVarsMap;
}
const queryString = `FOR node IN @ FILTER ${filterQuery} COLLECT WITH COUNT
INTO length RETURN length`;
const bindVars = Object.assign({
'@collection': collectionName
}, varArgs);
const res = await query(this.db, collectionName, queryString, bindVars);
const nn = await res.all();
return nn[0];
}
/**
* When calling without a collection name,
* delete all documents in all collections in the database.
* When providing a collection name,
* delete all documents in specified collection in the database.
* @param [string] collection Collection name.
*/
async truncate(collection) {
if (isNullish(collection)) {
const collections = await this.db.collections();
for (let i = 0; i < collections.length; i += 1) {
const c = this.db.collection(collections[i].name);
await c.truncate();
}
}
else {
const c = this.db.collection(collection);
await c.truncate();
}
}
/**
* Drop view
* @param string[] list of view names.
*/
async dropView(viewName) {
const dropViewResponse = [];
if (viewName.length > 0) {
for (const view of viewName) {
try {
const response = await this.db.view(view).drop();
this.logger?.info(`View ${view} dropped successfully`, response);
if (response === true) {
dropViewResponse.push({ id: view, code: 200, message: `View ${view} dropped successfully` });
}
}
catch (err) {
this.logger?.error(`Error dropping View ${view}`, { code: err.code, message: err.message, stack: err.stack });
dropViewResponse.push({ id: view, code: err.code, message: err.message });
}
}
}
return dropViewResponse;
}
/**
* Delete Analyzer
* @param string[] list of analyzer names.
*/
async deleteAnalyzer(analyzerName) {
const deleteResponse = [];
if (analyzerName.length > 0) {
for (const analyzer of analyzerName) {
try {
const response = await this.db.analyzer(analyzer).drop();
this.logger?.info(`Analyzer ${analyzer} deleted successfully`, response);
if (response.code === 200 && response.error === false) {
deleteResponse.push({ id: analyzer, code: response.code, message: `Analyzer ${analyzer} deleted successfully` });
}
}
catch (err) {
this.logger?.error(`Error deleting analyzer ${analyzer}`, { code: err.code, message: err.message, stack: err.stack });
deleteResponse.push({ id: analyzer, code: err.code, message: err.message });
}
}
}
return deleteResponse;
}
/**
* Insert documents into database.
*
* @param {String} collection Collection name
* @param {Object|array.Object} documents A single or multiple documents.
*/
async insert(collectionName, documents) {
if (isNullish(collectionName) || !isString(collectionName) || isEmptyish(collectionName)) {
throw new LoggedError(this.logger, 'invalid or missing collection argument for insert operation');
}
if (isNullish(documents)) {
throw new LoggedError(this.logger, 'invalid or missing documents argument for insert operation');
}
let docs = clone(documents);
if (!Array.isArray(documents)) {
docs = [documents];
}
forEach(docs, (document, i) => {
docs[i] = sanitizeInputFields(document);
});
const collection = this.db.collection(collectionName);
const collectionExists = await collection.exists();
if (!collectionExists) {
await collection.create().catch(error => {
const { code, message, details, stack } = error;
throw new LoggedError(this.logger, `Collection ${collectionName} not created!`, { code, message, details, stack });
});
}
const insertResponse = [];
let createdDocs = await collection.saveAll(docs, { returnNew: true });
if (!Array.isArray(createdDocs)) {
createdDocs = [createdDocs];
}
for (const doc of createdDocs) {
if ('new' in doc) {
insertResponse.push(sanitizeOutputFields(doc.new));
}
else {
insertResponse.push(doc);
}
}
return insertResponse;
}
/**
* Registers a custom AQL query.
*
* @param script
* @param name
*/
// @ts-expect-error TS2416
registerCustomQuery(name, script, type) {
this.customQueries.set(name, {
code: script,
type
});
}
/**
* Unregisters a custom query.
* @param name
*/
unregisterCustomQuery(name) {
if (!this.customQueries.has(name)) {
throw new LoggedError(this.logger, 'custom function not found');
}
this.customQueries.delete(name);
}
listCustomQueries() {
return [...this.customQueries];
}
async createAnalyzerAndView(viewConfig, collectionName) {
if (!viewConfig.view.viewName || !viewConfig?.view?.options) {
throw new LoggedError(this.logger, `View name or view configuration missing for ${collectionName}`);
}
if ((!viewConfig?.analyzers) || (viewConfig.analyzers.length === 0) || !(viewConfig.analyzerOptions)) {
throw new LoggedError(this.logger, `Analyzer options or configuration missing for ${collectionName}`);
}
// create analyzer if it does not exist
for (const analyzerName of viewConfig.analyzers) {
const analyzer = this.db.analyzer(analyzerName);
if (!(await analyzer.exists())) {
try {
const analyzerCfg = viewConfig.analyzerOptions.filter((optionsCfg) => Object.keys(optionsCfg)[0] === analyzerName);
if (analyzerCfg?.length === 1) {
await analyzer.create(analyzerCfg[0][analyzerName]);
this.logger?.info(`Analyzer ${analyzerName} created successfully`);
}
}
catch (err) {
const { code, message, details, stack } = err;
this.logger?.error(`Error creating analyzer ${analyzerName}`, { code, message, details, stack });
}
}
else {
this.logger?.info(`Analyzer ${analyzerName} already exists`);
}
}
// check if collection exits (before creating view)
const collection = this.db.collection(collectionName);
const collectionExists = await collection.exists();
try {
if (!collectionExists) {
await collection.create();
this.logger?.info(`Collection ${collectionName} created successfully`);
}
}
catch (err) {
if (err?.message?.includes('duplicate name')) {
this.logger?.warn(err.message);
}
else {
const { code, message, details, stack } = err;
this.logger?.error(`Error creating collection ${collectionName}`, { code, message, details, stack });
throw err;
}
}
// create view if it does not exist
const view = this.db.view(viewConfig.view.viewName);
const viewExists = await view.exists();
if (!viewExists) {
try {
await this.db.createView(viewConfig?.view?.viewName, viewConfig?.view?.options);
this.logger?.info(`View ${viewConfig?.view?.viewName} created successfully`);
}
catch (err) {
this.logger?.error(`Error creating View ${viewConfig?.view?.viewName}`, { code: err.code, message: err.message, stack: err.stack });
}
}
else {
this.logger?.info(`View ${viewConfig?.view?.viewName} already exists`);
}
// map the collectionName with list of indexed fields, view name, analyzerslist, similarity threshold
// to be used in find()
const indexedFields = Object.keys(viewConfig.view.options.links[collectionName].fields);
this.collectionNameAnalyzerMap.set(collectionName, {
viewName: viewConfig.view.viewName,
fields: indexedFields,
analyzerOptions: viewConfig.analyzerOptions,
similarityThreshold: viewConfig.view.similarityThreshold
});
}
}
//# sourceMappingURL=base.js.map