lemon-core
Version:
Lemon Serverless Micro-Service Platform
510 lines • 22 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Elastic6QueryService = void 0;
/**
* `elastic6-query-service.ts`
* - common service to query with id via elastic6
*
*
* @author Steve Jung <steve@lemoncloud.io>
* @date 2019-11-20 initial version via backbone
* @date 2021-12-07 support SearchBody
*
* @copyright (C) 2019 LemonCloud Co Ltd. - All Rights Reserved.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const engine_1 = require("../../engine/");
const elastic6_service_1 = require("./elastic6-service");
const hangul_service_1 = __importDefault(require("./hangul-service"));
const NS = engine_1.$U.NS('ES6Q', 'green'); // NAMESPACE TO BE PRINTED.
/**
* class: `Elastic6QueryService`
* - support simple query like range search.
*/
class Elastic6QueryService {
/**
* use query w/ the given search-service.
* @param service the origin search-service to use.
*/
constructor(service) {
/**
* say hello of identity.
*/
this.hello = () => `elastic6-query-service:${this.options.indexName}`;
/**
* build query parameter from search param.
*/
this.buildQueryBody = (param) => {
//! parameters.
let $query = null;
let $source = null;
let $page = -1;
let $limit = -1;
let $A = ''; // Aggregation
let $O = ''; // OrderBy
let $H = ''; // Highlight
//! build query.
const queries = Object.keys(param).reduce((list, key) => {
let val = param[key];
// ignore internal values.
if (key.startsWith('_'))
return list;
// _log(NS, `>> param[${key}] = `, val);
if (key === '$query') {
$query = {
query: typeof val === 'object' ? val : typeof val === 'string' ? JSON.parse(val) : `${val || ''}`,
};
}
else if (key === '$limit') {
$limit = engine_1.$U.N(val, 0);
}
else if (key === '$page') {
$page = engine_1.$U.N(val, 0);
}
else if (key === '$Q') {
if (!val) {
//NOP;
}
else if (typeof val === 'object') {
// ONLY IF object. use it as raw query.
$query = val;
}
else if (typeof val === 'string' && val.startsWith('{') && val.endsWith('}')) {
// might be the json data.
$query = JSON.parse(val);
}
else if (typeof val === 'string') {
// might be query string.
//! escape queries..
// + - = && || > < ! ( ) { } [ ] ^ " ~ * ? : \ /
// val = val.replace(/([\(\)])/ig,'\\$1'); //TODO - 이걸 무시하면, 중복 조건 검색에 문제가 생김, 하여 일단 안하는걸루. @180828.
list.push(`(${val})`);
}
}
else if (key === '$A') {
$A = `${val}`.trim(); // ',' delimited terms to count
}
else if (key === '$O') {
$O = `${val}`.trim(); // ',' delimited terms to order
}
else if (key === '$H') {
$H = `${val}`.trim(); // ',' delimited terms to highlight
}
else if (key === '$source') {
// returned source fields set. '*', 'obj.*', '!abc'
if (val === '*') {
// all.
$source = '*';
}
else if (typeof val === 'string' && val.indexOf !== undefined) {
// string array set.
const vals = val.split(',') || [];
const $includes = [];
const $excludes = [];
vals.forEach(val => {
val = `${val || ''}`.trim();
if (!val)
return;
if (val.startsWith('!')) {
$excludes.push(val.substr(1));
}
else {
$includes.push(val);
}
});
$source = { includes: $includes, excludes: $excludes };
}
else {
$source = val;
}
}
else if (key === '$exist' || key === '$exists') {
(Array.isArray(val) ? val : `${val}`.split(',') || []).forEach((val) => {
val = `${val || ''}`.trim();
if (!val)
return;
if (val.startsWith('!')) {
list.push('NOT _exists_:' + val.substr(1));
}
else {
list.push('_exists_:' + val);
}
});
}
else {
//! escape if there is ' ' except like '(a AND B)'
const escape_val = (val) => {
if (typeof val === 'string' && val === '') {
return '"' + val + '"';
}
else if (val && typeof val === 'string') {
if (val.startsWith('(') && val.endsWith(')')) {
// nop
}
else if (val.startsWith('"') && val.endsWith('"')) {
// must be string block
return val;
}
else if (val.indexOf(',') > 0) {
// list of array.
return val.split(',').map(s => {
return (s || '').trim();
});
}
else if (
// special chars
val.indexOf(' ') >= 0 ||
val.indexOf('\n') >= 0 ||
val.indexOf(':') >= 0 ||
val.indexOf('\\') >= 0 ||
val.indexOf('#') >= 0 ||
val.indexOf('^') >= 0) {
const str = val.replace(/([\"\'])/gi, '\\$1'); // replace '"' -> '\"'
return '"' + str + '"';
}
}
return val;
};
val = escape_val(val);
//! add to query-list.
if (key.startsWith('!')) {
if (val) {
if (Array.isArray(val)) {
const vals = val.map((_) => escape_val(_));
list.push(key.substr(1) + ':(NOT (' + vals.join(' OR ') + '))');
}
else {
list.push(key.substr(1) + ':(NOT ' + val + ')');
}
}
else {
list.push('_exists_:' + key.substr(1));
}
}
else if (key.startsWith('#')) {
// projection.
$source = $source || { includes: [], excludes: [] };
if ($source && $source.includes) {
$source.includes.push(key.substr(1));
}
}
else if (val === undefined) {
//! nop
}
else if (val && Array.isArray(val)) {
// list.push('(' + val.map(val => `${key}:${val}`).join(' OR ') + ')');
list.push(`${key}:` + '(' + val.map((val) => `${escape_val(val)}`).join(' OR ') + ')');
}
else {
list.push(`${key}:${val}`);
}
}
return list;
}, []);
//! prepare returned body.
const $body = $query
? $query
: (queries.length && { query: { query_string: { query: queries.join(' AND ') } } }) || {}; // $query 이게 있으면 그냥 이걸 이용.
//! Aggregation.
if ($A) {
// const $aggs = {
// // "types_count" : { "value_count" : { "field" : "brand" } }
// "types_count" : { "terms" : { "field" : "brand" } }
// }
const $aggs = $A.split(',').reduce(($a, val) => {
val = ('' + val).trim();
if (val) {
if (val.indexOf(':') > 0) {
// must be size.
const [nm, size] = val.split(':', 2);
$a[nm] = { terms: { field: nm, size: parseInt(size) } };
}
else {
$a[val] = { terms: { field: val } };
}
}
return $a;
}, {});
$body['aggs'] = $aggs;
}
//! OrderBy.
if ($O) {
//see sorting: see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html.
const $sort = $O.split(',').reduce(($a, val) => {
val = ('' + val).trim();
if (val) {
let name = val;
let asc = true;
if (val.startsWith('!')) {
// reverse
name = val.slice(1);
asc = false;
}
if (name) {
$a.push({ [name]: { order: asc ? 'asc' : 'desc' } });
}
}
return $a;
}, []);
if ($sort.length) {
$body.sort = $sort;
}
}
//! Highlight.
if ($H) {
const $highlight = $H.split(',').reduce(($h, val) => {
val = ('' + val).trim();
if (val) {
$h[val] = { type: 'unified' };
}
return $h;
}, {});
$body['highlight'] = {};
$body['highlight']['fields'] = $highlight;
}
//! if valid limit, then paginating.
if ($limit > -1) {
$body.size = $limit;
if ($page > -1) {
// starts from 0
$body.from = $page * $limit;
}
}
//! field projection with _source parameter.
if ($source !== null)
$body._source = $source;
//! returns body.
return $body;
};
const options = service.options;
if (!options.indexName)
throw new Error('.indexName is required');
this.service = service;
}
/**
* get options
*/
get options() {
return this.service.options;
}
/**
* query all by id.
*
* @param id
* @param limit
* @param isDesc
*/
queryAll(id, limit, isDesc) {
return __awaiter(this, void 0, void 0, function* () {
const { idName } = this.options;
const param = {
[idName]: id,
};
if (limit !== undefined)
param['$limit'] = limit;
if (isDesc !== undefined)
param['$O'] = (isDesc ? '!' : '') + id;
return this.searchSimple(param);
});
}
/**
* search in simple mode
* - 기본적으로 'mini-language'를 그대로 지원하도록한다.
* - 입력의 파라마터의 키값은 테스트할 필드들이다.
* {"stock":">1"} => query_string : "stock:>1"
*
* - 파라미터 예약:
* $query : ES _search 용 쿼리를 그대로 이용.
* $exist : 'a,!b,c' => a AND NOT b AND c 를 _exists_ 항목으로 풀어씀.
* $source : _source 항목에 포함될 내용. (undefined => _source:false)
* $limit : same as "size"
* $page : same as "from" / "size" ($limit 를 ipp 으로 함축하여 이용).
*
*
*
* [Mini-Language]
* ```
* # find title field which contains quick or brown.
* title:(quick OR brown)
*
* # not-null value.
* _exists_:title
*
* # regular exp.
* name:/joh?n(ath[oa]n)/
* ```
*
*
* 참고: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#query-string-syntax
* 참고: http://okfnlabs.org/blog/2013/07/01/elasticsearch-query-tutorial.html
*
* @param param search param
*/
searchSimple(param) {
return __awaiter(this, void 0, void 0, function* () {
if (!param)
throw new Error('@param (SimpleSearchParam) is required');
const { indexName } = this.options;
(0, engine_1._log)(NS, `- searchSimple(${indexName})....`);
(0, engine_1._log)(NS, `> param =`, engine_1.$U.json(param));
//! build query body.
const body = this.buildQueryBody(param);
//! search via client
const res = yield this.search(body);
//! convert to query-result.
return this.asQueryResult(body, res);
});
}
/**
* search with raw query language.
*
* @param body SearchBody.
* @returns results.
*/
search(body, searchType) {
return __awaiter(this, void 0, void 0, function* () {
if (!body)
throw new Error('@body (SearchBody) is required');
return this.service.searchRaw(body, searchType);
});
}
/**
* convert result as `QueryResult`
* @param body the query body requested
* @param res the result
* @returns QueryResult
*/
asQueryResult(body, res) {
var _a, _b, _c;
const size = engine_1.$U.N(body === null || body === void 0 ? void 0 : body.size, 10);
//! extract for result.
const hits = res === null || res === void 0 ? void 0 : res.hits;
if (typeof hits !== 'object')
throw new Error(`.hits (object) is required - hists:${engine_1.$U.json(hits)}`);
const total = engine_1.$U.N(typeof ((_a = hits.total) === null || _a === void 0 ? void 0 : _a.value) === 'number' ? (_b = hits.total) === null || _b === void 0 ? void 0 : _b.value : hits.total, 0); // since v7.x
const last = (hits === null || hits === void 0 ? void 0 : hits.hits.length) === size ? (_c = hits.hits[size - 1]) === null || _c === void 0 ? void 0 : _c.sort : undefined;
const list = ((hits && hits.hits) || []).map((N) => {
const id = N && N._id; // id of elastic-search
const score = N && N._score; // search score.
const source = N && N._source; // origin data
//! save as internal
source._id = source._id || id; // attach to internal-id
source._score = score;
// delete internal autocomplete data
delete source[elastic6_service_1.Elastic6Service.DECOMPOSED_FIELD];
delete source[elastic6_service_1.Elastic6Service.QWERTY_FIELD];
return source;
});
const result = { list, total, last };
if (res === null || res === void 0 ? void 0 : res.aggregations) {
const $aggregations = res.aggregations || {};
result.aggregations = Object.keys($aggregations).reduce((aggrs, field) => {
const { doc_count_error_upper_bound: docCountError = 0, sum_other_doc_count: docSkippedCount = 0, buckets, } = res.aggregations[field];
if (docCountError > 0)
(0, engine_1._err)(NS, `> [WARN] aggregation: counts for each term are not accurate.`);
if (docSkippedCount > 0)
(0, engine_1._err)(NS, '> [WARN] aggregation: too many unique terms in the result. some terms are skipped.');
if (Array.isArray(buckets)) {
aggrs[field] = buckets.map((bucket) => {
return { key: bucket.key, count: bucket.doc_count };
});
}
return aggrs;
}, {});
}
return result;
}
/**
* convert `AutocompleteSearchParam` to `SearchBody`
* @param param AutocompleteSearchParam
* @returns SearchBody
*/
asSearchBody(param) {
const { autocompleteFields } = this.options;
// validate parameters
if (!param)
throw new Error('@param (AutocompleteSearchParam) is required');
if (!param.$query || !Object.keys(param.$query).length)
throw new Error('.query is required');
if (Object.keys(param.$query).length > 1)
throw new Error('.query accepts only one property');
const [field, query] = Object.entries(param.$query)[0];
if (!field || !query)
throw new Error(`.query is invalid`);
if (!autocompleteFields.includes(field))
throw new Error(`.query has no autocomplete field`);
// build query body
const decomposedField = `${elastic6_service_1.Elastic6Service.DECOMPOSED_FIELD}.${field}`;
const qwertyField = `${elastic6_service_1.Elastic6Service.QWERTY_FIELD}.${field}`;
const body = {
query: {
bool: {
should: [
{ match: { [decomposedField]: hangul_service_1.default.asJamoSequence(query) } },
{ match: { [qwertyField]: query } },
],
minimum_should_match: 1,
},
},
};
if (param.$filter) {
body.query.bool.filter = Object.entries(param.$filter).map(([field, filter]) => {
return { term: { [field]: filter } };
});
}
body.size = engine_1.$U.N(param.$limit, 10);
body.from = engine_1.$U.N(param.$page, 0) * body.size;
return { field, query, body };
}
/**
* search item in Search-as-You-Type way
* @param param AutocompleteSearchParam
*/
searchAutocomplete(param) {
return __awaiter(this, void 0, void 0, function* () {
const { field, query, body } = this.asSearchBody(param);
const res = yield this.service.searchRaw(body);
const result = this.asQueryResult(body, res);
// highlighting result manually
let list = result.list;
if (param.$highlight) {
// prepare tag name to wrap highlighted text
const tagName = typeof param.$highlight == 'string' ? param.$highlight : 'em';
// create a regular expression which has optional whitespaces between each character
// e.g. 'COVID-19' => /C *O *V *I *D *- *1 *9/i
const regexp = new RegExp([...query.replace(/\s/g, '')].join(' *'), 'i');
// try to match regular expression with items found
list = result.list.map((item) => {
const target = `${item[field] || ''}`;
const match = target.match(regexp);
if (match) {
item._highlight =
target.slice(0, match.index) +
`<${tagName}>${match[0]}</${tagName}>` +
target.slice(match.index + match[0].length);
}
else {
item._highlight = target;
}
return item;
});
}
//! finally, override list.
return Object.assign(Object.assign({}, result), { list });
});
}
}
exports.Elastic6QueryService = Elastic6QueryService;
//# sourceMappingURL=elastic6-query-service.js.map
;