@opra/elastic
Version:
Opra Elastic Search adapter package
554 lines (553 loc) • 19.4 kB
JavaScript
import { DATATYPE_METADATA, InternalServerError, } from '@opra/common';
import { isNotNullish } from 'valgen';
import { ElasticAdapter } from './elastic-adapter.js';
import { ElasticService } from './elastic-service.js';
/**
* @class ElasticEntityService
* @template T - The type of the documents in the collection.
*/
export class ElasticEntityService extends ElasticService {
_dataTypeScope;
_dataType_;
_dataType;
_inputCodecs = {};
_outputCodecs = {};
/**
* Defines comma delimited scopes for api document
*/
scope;
/**
* Represents the name of a index in ElasticDB
*/
indexName;
/**
* Represents the name of a resource.
* @type {string}
*/
resourceName;
/**
* Generates a new id for new inserting Document.
*
*/
idGenerator;
/**
* Constructs a new instance
*
* @param {Type | string} dataType - The data type of the array elements.
* @param {ElasticEntityService.Options} [options] - The options for the array service.
* @constructor
*/
constructor(dataType, options) {
super(options);
this._dataType_ = dataType;
if (options?.indexName)
this.indexName = options?.indexName;
else {
if (typeof dataType === 'string')
this.indexName = dataType;
if (typeof dataType === 'function') {
const metadata = Reflect.getMetadata(DATATYPE_METADATA, dataType);
if (metadata)
this.indexName = metadata.name;
}
}
this.resourceName = options?.resourceName;
this.idGenerator = options?.idGenerator;
}
/**
* Retrieves the index name.
*
* @protected
* @returns The index name.
* @throws {Error} If the index name is not defined.
*/
getIndexName() {
const out = typeof this.indexName === 'function'
? this.indexName(this)
: this.indexName;
if (out)
return out;
throw new Error('indexName is not defined');
}
/**
* Retrieves the resource name.
*
* @protected
* @returns {string} The resource name.
* @throws {Error} If the resource name is not defined.
*/
getResourceName() {
const out = typeof this.resourceName === 'function'
? this.resourceName(this)
: this.resourceName || this.getIndexName();
if (out)
return out;
throw new Error('resourceName is not defined');
}
/**
* Retrieves the OPRA data type
*
* @throws {NotAcceptableError} If the data type is not a ComplexType.
*/
get dataType() {
if (this._dataType && this._dataTypeScope !== this.scope)
this._dataType = undefined;
if (!this._dataType)
this._dataType = this.context.__docNode.getComplexType(this._dataType_);
this._dataTypeScope = this.scope;
return this._dataType;
}
/**
* Adds a JSON document to the specified data stream or index and makes it searchable.
* If the target is an index and the document already exists,
* the request updates the document and increments its version.
*
* @param {ElasticEntityService.CreateCommand} command
* @protected
*/
async _create(command) {
const input = command.input;
isNotNullish(input, { label: 'input' });
isNotNullish(input._id, { label: 'input._id' });
const inputCodec = this._getInputCodec('create');
const doc = inputCodec(input);
delete doc._id;
const { options } = command;
const request = {
...options?.request,
index: this.getIndexName(),
id: input._id,
document: doc,
};
const r = await this.__create(request, options);
/* istanbul ignore next */
if (!(r._id && (r.result === 'created' || r.result === 'updated'))) {
throw new InternalServerError(`Unknown error while creating document for "${this.getResourceName()}"`);
}
return r;
}
async __create(request, options) {
const client = this.getClient();
return options?.replaceIfExists
? await client.index(request, options?.transport)
: await client.create(request, options?.transport);
}
/**
* Returns the count of documents in the collection based on the provided options.
*
* @param {ElasticEntityService.CountCommand} command
* @protected
*/
async _count(command) {
const { options } = command;
const filterQuery = ElasticAdapter.prepareFilter([
options?.filter,
options?.request?.query,
]);
let query = {
...options?.request?.query,
...filterQuery,
};
if (!Object.keys(query).length)
query = undefined;
const request = {
index: this.getIndexName(),
...options?.request,
query,
};
return this.__count(request, options);
}
__count(request, options) {
const client = this.getClient();
return client.count(request, options?.transport);
}
/**
* Deletes a document from the collection.
*
* @param {ElasticEntityService.DeleteCommand} command
* @protected
*/
async _delete(command) {
isNotNullish(command.documentId, { label: 'documentId' });
const { options } = command;
const filterQuery = ElasticAdapter.prepareFilter([
{ ids: { values: [command.documentId] } },
options?.filter,
options?.request?.query,
]);
let query = {
...options?.request?.query,
...filterQuery,
};
if (!Object.keys(query).length)
query = { match_all: {} };
const request = {
index: this.getIndexName(),
...options?.request,
query,
};
return this.__delete(request, options);
}
/**
* Deletes multiple documents from the collection that meet the specified filter criteria.
*
* @param {ElasticEntityService.DeleteManyCommand} command
* @protected
*/
async _deleteMany(command) {
const { options } = command;
const filterQuery = ElasticAdapter.prepareFilter([
options?.filter,
options?.request?.query,
]);
let query = {
...options?.request?.query,
...filterQuery,
};
if (!Object.keys(query).length)
query = { match_all: {} };
const request = {
...options?.request,
index: this.getIndexName(),
query,
};
return await this.__delete(request, options);
}
async __delete(request, options) {
const client = this.getClient();
return client.deleteByQuery(request, options?.transport);
}
/**
* Returns search hits that match the query defined in the request
*
* @param {ElasticEntityService.FindManyCommand} command
*/
async _findMany(command) {
const { options } = command;
const filterQuery = ElasticAdapter.prepareFilter([
command.documentId
? { ids: { values: [command.documentId] } }
: undefined,
options?.filter,
// options?.request?.query,
]);
let query = this._mergeQueries(options?.request?.query, filterQuery);
if (!(query && Object.keys(query).length))
query = { match_all: {} };
const request = {
from: options?.skip,
size: options?.limit,
sort: options?.sort
? ElasticAdapter.prepareSort(options?.sort)
: undefined,
_source: ElasticAdapter.prepareProjection(this.dataType, options?.projection, this._dataTypeScope),
index: this.getIndexName(),
...options?.request,
query,
};
const r = await this.__findMany(request, options);
if (options?.noDecode)
return r;
if (r.hits.hits?.length) {
const outputCodec = this.getOutputCodec('find');
r.hits.hits = r.hits.hits.map((x) => ({
...x,
_source: {
_id: x._id,
...outputCodec(x._source),
},
}));
}
return r;
}
async __findMany(request, options) {
const client = this.getClient();
return client.search(request, options?.transport);
}
/**
* Executes a search operation on the Elasticsearch index using the provided search command.
*
* @param {ElasticEntityService.SearchCommand} command - The search command containing the request configuration and optional transport settings.
* @return {Promise<ElasticEntityService.SearchResponse>} A promise resolving to the search response from Elasticsearch.
*/
async _searchRaw(command) {
const { options } = command;
const request = {
index: this.getIndexName(),
...command.request,
};
const client = this.getClient();
const r = await client.search(request, options?.transport);
if (r.hits.hits?.length) {
const outputCodec = this.getOutputCodec('find');
r.hits.hits = r.hits.hits.map((x) => ({
...x,
_source: {
_id: x._id,
...outputCodec(x._source),
},
}));
}
return r;
}
/**
* Updates multiple documents in the collection based on the specified input and options.
*
* @param {ElasticEntityService.UpdateCommand<T>} command
*/
async _updateMany(command) {
if (command.byId)
isNotNullish(command.documentId, { label: 'documentId' });
const { options } = command;
const input = command.input;
const requestScript = command.options?.request?.script;
let script;
const inputKeysLen = Object.keys(input).length;
isNotNullish(inputKeysLen || script, { label: 'input' });
if (requestScript) {
if (typeof requestScript === 'string')
script = { source: requestScript };
else if (requestScript.source || requestScript.id)
script = { ...requestScript };
else
script = { source: requestScript };
script.lang = script.lang || 'painless';
if (inputKeysLen > 0 && script.lang !== 'painless') {
throw new TypeError(`You cannot provide 'input' and 'script' arguments at the same time unless the script lang is 'painless'`);
}
}
if (inputKeysLen) {
delete input._id;
const inputCodec = this._getInputCodec('update');
const doc = inputCodec(input);
const scr = ElasticAdapter.preparePatch(doc);
if (script) {
script.source =
(script.source ? script.source + '\n' + script.source : '') +
scr.source;
script.params = { ...script.params, ...scr.params };
}
else
script = scr;
}
script.source = script?.source || 'return;';
const filterQuery = ElasticAdapter.prepareFilter([
command.byId ? { ids: { values: [command.documentId] } } : undefined,
options?.filter,
options?.request?.query,
]);
let query = {
...options?.request?.query,
...filterQuery,
};
if (!Object.keys(query).length)
query = { match_all: {} };
const request = {
...options?.request,
index: this.getIndexName(),
script,
query,
};
return await this.__update(request, options);
}
async __update(request, options) {
const client = this.getClient();
return client.updateByQuery(request, options?.transport);
}
/**
* Generates an ID.
*
* @protected
* @returns The generated ID.
*/
_generateId(command) {
return typeof this.idGenerator === 'function'
? this.idGenerator(command, this)
: undefined;
}
/**
* Retrieves the codec for the specified operation.
*
* @param operation - The operation to retrieve the encoder for. Valid values are 'create' and 'update'.
*/
_getInputCodec(operation) {
const dataType = this.dataType;
const cacheKey = operation + (this._dataTypeScope ? ':' + this._dataTypeScope : '');
let validator = this._inputCodecs[cacheKey];
if (validator)
return validator;
const options = {
projection: '*',
scope: this._dataTypeScope,
};
if (operation === 'update') {
options.partial = 'deep';
options.keepKeyFields = true;
}
validator = dataType.generateCodec('decode', options);
this._inputCodecs[cacheKey] = validator;
return validator;
}
/**
* Retrieves the codec.
*/
getOutputCodec(operation) {
const cacheKey = operation + (this._dataTypeScope ? ':' + this._dataTypeScope : '');
let validator = this._outputCodecs[cacheKey];
if (validator)
return validator;
const options = {
projection: '*',
partial: 'deep',
scope: this._dataTypeScope,
};
const dataType = this.dataType;
validator = dataType.generateCodec('decode', options);
this._outputCodecs[cacheKey] = validator;
return validator;
}
async _executeCommand(command, commandFn) {
try {
const result = await super._executeCommand(command, async () => {
/** Call before[X] hooks */
if (command.crud === 'create')
await this._beforeCreate(command);
else if (command.crud === 'update' && command.byId) {
await this._beforeUpdate(command);
}
else if (command.crud === 'update' && !command.byId) {
await this._beforeUpdateMany(command);
}
else if (command.crud === 'delete' && command.byId) {
await this._beforeDelete(command);
}
else if (command.crud === 'delete' && !command.byId) {
await this._beforeDeleteMany(command);
}
/** Call command function */
return commandFn();
});
/** Call after[X] hooks */
if (command.crud === 'create')
await this._afterCreate(command, result);
else if (command.crud === 'update' && command.byId) {
await this._afterUpdate(command, result);
}
else if (command.crud === 'update' && !command.byId) {
await this._afterUpdateMany(command, result);
}
else if (command.crud === 'delete' && command.byId) {
await this._afterDelete(command, result);
}
else if (command.crud === 'delete' && !command.byId) {
await this._afterDeleteMany(command, result);
}
return result;
}
catch (e) {
Error.captureStackTrace(e, this._executeCommand);
await this.onError?.(e, this);
throw e;
}
}
_mergeQueries(requestQuery, filterQuery) {
if (requestQuery) {
let subQuery = false;
if (requestQuery.function_score) {
subQuery = true;
if (Array.isArray(requestQuery.function_score)) {
requestQuery.function_score.forEach(item => {
item.filter = this._mergeQueries(item.filter, filterQuery);
});
}
else {
requestQuery.function_score.query = this._mergeQueries(requestQuery.function_score.query, filterQuery);
}
}
if (requestQuery.dis_max) {
subQuery = true;
requestQuery.dis_max.queries?.map(q => this._mergeQueries(q, filterQuery));
}
if (requestQuery.constant_score) {
subQuery = true;
requestQuery.constant_score.filter = this._mergeQueries(requestQuery.constant_score.filter, filterQuery);
}
if (requestQuery.has_child) {
subQuery = true;
requestQuery.has_child.query = this._mergeQueries(requestQuery.has_child.query, filterQuery);
}
if (requestQuery.has_parent) {
subQuery = true;
requestQuery.has_parent.query = this._mergeQueries(requestQuery.has_parent.query, filterQuery);
}
if (requestQuery.script_score) {
subQuery = true;
requestQuery.script_score.query = this._mergeQueries(requestQuery.script_score.query, filterQuery);
}
return subQuery
? requestQuery
: ElasticAdapter.prepareFilter([requestQuery, filterQuery]);
}
return filterQuery;
}
async _beforeCreate(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
command) {
// Do nothing
}
async _beforeUpdate(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
command) {
// Do nothing
}
async _beforeUpdateMany(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
command) {
// Do nothing
}
async _beforeDelete(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
command) {
// Do nothing
}
async _beforeDeleteMany(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
command) {
// Do nothing
}
async _afterCreate(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
command,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
result) {
// Do nothing
}
async _afterUpdate(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
command,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
result) {
// Do nothing
}
async _afterUpdateMany(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
command,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
affected) {
// Do nothing
}
async _afterDelete(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
command,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
affected) {
// Do nothing
}
async _afterDeleteMany(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
command,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
affected) {
// Do nothing
}
}