UNPKG

@travetto/model-elasticsearch

Version:

Elasticsearch backing for the travetto model module, with real-time modeling support for Elasticsearch mappings.

271 lines (251 loc) 8.89 kB
import { estypes } from '@elastic/elasticsearch'; import { castTo, Class, TypedObject } from '@travetto/runtime'; import { WhereClause, SelectClause, SortClause, Query, ModelQueryUtil } from '@travetto/model-query'; import { IndexConfig, ModelType, ModelRegistry } from '@travetto/model'; import { DataUtil, SchemaRegistry } from '@travetto/schema'; import { EsSchemaConfig } from './types.ts'; /** * Support tools for dealing with elasticsearch specific requirements */ export class ElasticsearchQueryUtil { /** * Convert `a.b.c` to `a : { b : { c : ... }}` */ static extractSimple<T>(o: T, path: string = ''): Record<string, unknown> { const out: Record<string, unknown> = {}; const keys = TypedObject.keys(o); for (const key of keys) { const subPath = `${path}${key}`; if (DataUtil.isPlainObject(o[key]) && !Object.keys(o[key])[0].startsWith('$')) { Object.assign(out, this.extractSimple(o[key], `${subPath}.`)); } else { out[subPath] = o[key]; } } return out; } /** * Build include/exclude from the select clause */ static getSelect<T>(clause: SelectClause<T>): [string[], string[]] { const simp = this.extractSimple(clause); const include: string[] = []; const exclude: string[] = []; for (const k of Object.keys(simp)) { const nk = k === 'id' ? '_id' : k; const v: 1 | 0 | boolean = castTo(simp[k]); if (v === 0 || v === false) { exclude.push(nk); } else { include.push(nk); } } return [include, exclude]; } /** * Build sort mechanism */ static getSort<T extends ModelType>(sort: SortClause<T>[] | IndexConfig<T>['fields']): estypes.Sort { return sort.map<estypes.SortOptions>(x => { const o = this.extractSimple(x); const k = Object.keys(o)[0]; const v: boolean | -1 | 1 = castTo(o[k]); return { [k]: { order: v === 1 || v === true ? 'asc' : 'desc' } }; }); } /** * Extract specific term for a class, and a given field */ static extractWhereTermQuery<T>(cls: Class<T>, o: Record<string, unknown>, config?: EsSchemaConfig, path: string = ''): Record<string, unknown> { const items = []; const schema = SchemaRegistry.getViewSchema(cls).schema; for (const key of TypedObject.keys(o)) { const top = o[key]; const declaredSchema = schema[key]; const declaredType = declaredSchema.type; const sPath = declaredType === String ? ((key === 'id' && !path) ? '_id' : `${path}${key}`) : `${path}${key}`; const sPathQuery = (val: unknown): {} => (key === 'id' && !path) ? { ids: { values: Array.isArray(val) ? val : [val] } } : { [Array.isArray(val) ? 'terms' : 'term']: { [sPath]: val } }; if (DataUtil.isPlainObject(top)) { const subKey = Object.keys(top)[0]; if (!subKey.startsWith('$')) { const inner = this.extractWhereTermQuery(declaredType, top, config, `${sPath}.`); items.push(declaredSchema.array ? { nested: { path: sPath, query: inner } } : inner ); } else { const v = top[subKey]; switch (subKey) { case '$all': { const arr = Array.isArray(v) ? v : [v]; items.push({ bool: { must: arr.map(x => ({ term: { [sPath]: x } })) } }); break; } case '$in': { items.push(sPathQuery(Array.isArray(v) ? v : [v])); break; } case '$nin': { items.push({ bool: { ['must_not']: [sPathQuery(Array.isArray(v) ? v : [v])] } }); break; } case '$eq': { items.push(sPathQuery(v)); break; } case '$ne': { items.push({ bool: { ['must_not']: [sPathQuery(v)] } }); break; } case '$exists': { const q = { exists: { field: sPath } }; items.push(v ? q : { bool: { ['must_not']: q } }); break; } case '$lt': case '$gt': case '$gte': case '$lte': { const out: Record<string, unknown> = {}; for (const k of Object.keys(top)) { out[k.replace(/^[$]/, '')] = ModelQueryUtil.resolveComparator(top[k]); } items.push({ range: { [sPath]: out } }); break; } case '$regex': { const pattern = DataUtil.toRegex(castTo(v)); if (pattern.source.startsWith('\\b') && pattern.source.endsWith('.*')) { const textField = !pattern.flags.includes('i') && config && config.caseSensitive ? `${sPath}.text_cs` : `${sPath}.text`; const query = pattern.source.substring(2, pattern.source.length - 2); items.push({ ['match_phrase_prefix']: { [textField]: query } }); } else { items.push({ regexp: { [sPath]: pattern.source } }); } break; } case '$geoWithin': { items.push({ ['geo_polygon']: { [sPath]: { points: v } } }); break; } case '$unit': case '$maxDistance': case '$near': { let dist = top.$maxDistance; let unit = top.$unit ?? 'm'; if (unit === 'rad' && typeof dist === 'number') { dist = 6378.1 * dist; unit = 'km'; } items.push({ ['geo_distance']: { distance: `${dist}${unit}`, [sPath]: top.$near } }); break; } } } // Handle operations } else { items.push(sPathQuery(top)); } } if (items.length === 1) { return items[0]; } else { return { bool: { must: items } }; } } /** * Build query from the where clause */ static extractWhereQuery<T>(cls: Class<T>, o: WhereClause<T>, config?: EsSchemaConfig): Record<string, unknown> { if (ModelQueryUtil.has$And(o)) { return { bool: { must: o.$and.map(x => this.extractWhereQuery<T>(cls, x, config)) } }; } else if (ModelQueryUtil.has$Or(o)) { return { bool: { should: o.$or.map(x => this.extractWhereQuery<T>(cls, x, config)), ['minimum_should_match']: 1 } }; } else if (ModelQueryUtil.has$Not(o)) { return { bool: { ['must_not']: this.extractWhereQuery<T>(cls, o.$not, config) } }; } else { return this.extractWhereTermQuery(cls, o, config); } } /** * Generate final search query * @param cls * @param search */ static getSearchQuery<T extends ModelType>(cls: Class<T>, search: Record<string, unknown>, checkExpiry = true): estypes.QueryDslQueryContainer { const clauses = []; if (search && Object.keys(search).length) { clauses.push(search); } const { expiresAt, subType } = ModelRegistry.get(cls); if (checkExpiry && expiresAt) { clauses.push({ bool: { should: [ { exists: { field: expiresAt } }, { range: { [expiresAt]: { gte: new Date().toISOString() } } }, ], minimum_should_match: 1 }, }); } if (subType) { const { subTypeField, subTypeName } = SchemaRegistry.get(cls); clauses.push({ term: { [subTypeField]: { value: subTypeName } } }); } return clauses.length === 0 ? {} : clauses.length === 1 ? clauses[0] : { bool: { must: clauses } }; } /** * Build a base search object from a class and a query */ static getSearchObject<T extends ModelType>( cls: Class<T>, query: Query<T>, config?: EsSchemaConfig, checkExpiry = true ): estypes.SearchRequest & Omit<estypes.DeleteByQueryRequest, 'index' | 'sort'> { const search: (estypes.SearchRequest & Omit<estypes.DeleteByQueryRequest, 'index' | 'sort'>) = { query: this.getSearchQuery(cls, this.extractWhereQuery(cls, query.where ?? {}, config), checkExpiry) }; const sort = query.sort; if (query.select) { const [inc, exc] = this.getSelect(query.select); if (inc.length) { search._source_includes = inc; } if (exc.length) { search._source_excludes = exc; } } if (sort) { search.sort = this.getSort(sort); } if (query.offset && typeof query.offset === 'number') { search.from = query.offset; } if (query.limit) { search.size = query.limit; } return search; } }