UNPKG

@opra/elastic

Version:

Opra Elastic Search adapter package

554 lines (553 loc) 19.4 kB
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 } }