@travetto/model-elasticsearch
Version:
Elasticsearch backing for the travetto model module, with real-time modeling support for Elasticsearch mappings.
273 lines (253 loc) • 9.58 kB
text/typescript
import type { estypes } from '@elastic/elasticsearch';
import { castTo, type Class, TypedObject } from '@travetto/runtime';
import { type WhereClause, type SelectClause, type SortClause, type Query, ModelQueryUtil } from '@travetto/model-query';
import { type IndexConfig, type ModelType, ModelRegistryIndex } from '@travetto/model';
import { DataUtil, SchemaRegistryIndex } from '@travetto/schema';
import { type 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>(input: T, path: string = ''): Record<string, unknown> {
const out: Record<string, unknown> = {};
const keys = TypedObject.keys(input);
for (const key of keys) {
const subPath = `${path}${key}`;
if (DataUtil.isPlainObject(input[key]) && !Object.keys(input[key])[0].startsWith('$')) {
Object.assign(out, this.extractSimple(input[key], `${subPath}.`));
} else {
out[subPath] = input[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 key of Object.keys(simp)) {
const translatedKey = key === 'id' ? '_id' : key;
const value: 1 | 0 | boolean = castTo(simp[key]);
if (value === 0 || value === false) {
exclude.push(translatedKey);
} else {
include.push(translatedKey);
}
}
return [include, exclude];
}
/**
* Build sort mechanism
*/
static getSort<T extends ModelType>(sort: SortClause<T>[] | IndexConfig<T>['fields']): estypes.Sort {
return sort.map<estypes.SortOptions>(option => {
const item = this.extractSimple(option);
const key = Object.keys(item)[0];
const value: boolean | -1 | 1 = castTo(item[key]);
return { [key]: { order: value === 1 || value === true ? 'asc' : 'desc' } };
});
}
/**
* Extract specific term for a class, and a given field
*/
static extractWhereTermQuery<T>(cls: Class<T>, item: Record<string, unknown>, config?: EsSchemaConfig, path: string = ''): Record<string, unknown> {
const items = [];
const fields = SchemaRegistryIndex.get(cls).getFields();
for (const property of TypedObject.keys(item)) {
const top = item[property];
const declaredSchema = fields[property];
const declaredType = declaredSchema.type;
const subPath = declaredType === String ?
((property === 'id' && !path) ? '_id' : `${path}${property}`) :
`${path}${property}`;
const subPathQuery = (value: unknown): {} => (property === 'id' && !path) ?
{ ids: { values: Array.isArray(value) ? value : [value] } } :
{ [Array.isArray(value) ? 'terms' : 'term']: { [subPath]: value } };
if (DataUtil.isPlainObject(top)) {
const subKey = Object.keys(top)[0];
if (!subKey.startsWith('$')) {
const inner = this.extractWhereTermQuery(declaredType, top, config, `${subPath}.`);
items.push(declaredSchema.array ?
{ nested: { path: subPath, query: inner } } :
inner
);
} else {
const value = top[subKey];
switch (subKey) {
case '$all': {
const values = Array.isArray(value) ? value : [value];
items.push({
bool: {
must: values.map(term => ({ term: { [subPath]: term } }))
}
});
break;
}
case '$in': {
items.push(subPathQuery(Array.isArray(value) ? value : [value]));
break;
}
case '$nin': {
items.push({ bool: { ['must_not']: [subPathQuery(Array.isArray(value) ? value : [value])] } });
break;
}
case '$eq': {
items.push(subPathQuery(value));
break;
}
case '$ne': {
items.push({ bool: { ['must_not']: [subPathQuery(value)] } });
break;
}
case '$exists': {
const clause = { exists: { field: subPath } };
items.push(value ? clause : { bool: { ['must_not']: clause } });
break;
}
case '$lt':
case '$gt':
case '$gte':
case '$lte': {
const out: Record<string, unknown> = {};
for (const key of Object.keys(top)) {
out[key.replace(/^[$]/, '')] = ModelQueryUtil.resolveComparator(top[key]);
}
items.push({ range: { [subPath]: out } });
break;
}
case '$regex': {
const pattern = DataUtil.toRegex(castTo(value));
if (pattern.source.startsWith('\\b') && pattern.source.endsWith('.*')) {
const textField = !pattern.flags.includes('i') && config && config.caseSensitive ?
`${subPath}.text_cs` :
`${subPath}.text`;
const query = pattern.source.substring(2, pattern.source.length - 2);
items.push({
['match_phrase_prefix']: {
[textField]: query
}
});
} else {
items.push({ regexp: { [subPath]: pattern.source } });
}
break;
}
case '$geoWithin': {
items.push({ ['geo_polygon']: { [subPath]: { points: value } } });
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}`,
[subPath]: top.$near
}
});
break;
}
}
}
// Handle operations
} else {
items.push(subPathQuery(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>, clause: WhereClause<T>, config?: EsSchemaConfig): Record<string, unknown> {
if (ModelQueryUtil.has$And(clause)) {
return { bool: { must: clause.$and.map(item => this.extractWhereQuery<T>(cls, item, config)) } };
} else if (ModelQueryUtil.has$Or(clause)) {
return { bool: { should: clause.$or.map(item => this.extractWhereQuery<T>(cls, item, config)), ['minimum_should_match']: 1 } };
} else if (ModelQueryUtil.has$Not(clause)) {
return { bool: { ['must_not']: this.extractWhereQuery<T>(cls, clause.$not, config) } };
} else {
return this.extractWhereTermQuery(cls, clause, 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: estypes.QueryDslQueryContainer[] = [];
if (search && Object.keys(search).length) {
clauses.push(search);
}
const { expiresAt } = ModelRegistryIndex.getConfig(cls);
if (checkExpiry && expiresAt) {
clauses.push({
bool: {
should: [
{ exists: { field: expiresAt } },
{ range: { [expiresAt]: { gte: new Date().toISOString() } } },
],
minimum_should_match: 1
},
});
}
const polymorphicConfig = SchemaRegistryIndex.getDiscriminatedConfig(cls);
if (polymorphicConfig) {
if (polymorphicConfig.discriminatedBase) {
clauses.push({ terms: { [polymorphicConfig.discriminatedField]: SchemaRegistryIndex.getDiscriminatedTypes(cls)! } });
} else {
clauses.push({ term: { [polymorphicConfig.discriminatedField]: { value: polymorphicConfig.discriminatedType } } });
}
}
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;
}
}