|
|
@@ -25,7 +25,15 @@ import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
|
|
|
import { aggregatePipelineToIndex } from './aggregate-to-index';
|
|
|
import type { AggregatedPage, BulkWriteBody, BulkWriteCommand } from './bulk-write';
|
|
|
import {
|
|
|
- getClient, type ElasticsearchClientDelegator, isES9ClientDelegator, isES7ClientDelegator, isES8ClientDelegator,
|
|
|
+ getClient,
|
|
|
+ isES7ClientDelegator,
|
|
|
+ isES8ClientDelegator,
|
|
|
+ isES9ClientDelegator,
|
|
|
+ type SearchQuery,
|
|
|
+ type ES7SearchQuery,
|
|
|
+ type ES8SearchQuery,
|
|
|
+ type ES9SearchQuery,
|
|
|
+ type ElasticsearchClientDelegator,
|
|
|
} from './elasticsearch-client-delegator';
|
|
|
|
|
|
const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
|
|
|
@@ -44,7 +52,7 @@ const ES_SORT_AXIS = {
|
|
|
const ES_SORT_ORDER = {
|
|
|
[DESC]: 'desc',
|
|
|
[ASC]: 'asc',
|
|
|
-};
|
|
|
+} as const;
|
|
|
|
|
|
const AVAILABLE_KEYS = ['match', 'not_match', 'phrase', 'not_phrase', 'prefix', 'not_prefix', 'tag', 'not_tag'];
|
|
|
|
|
|
@@ -551,26 +559,74 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
|
|
|
* data: [ pages ...],
|
|
|
* }
|
|
|
*/
|
|
|
- async searchKeyword(query): Promise<ISearchResult<ISearchResultData>> {
|
|
|
+ async searchKeyword(query: SearchQuery): Promise<ISearchResult<ISearchResultData>> {
|
|
|
|
|
|
// for debug
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
|
logger.debug('query: ', JSON.stringify(query, null, 2));
|
|
|
|
|
|
- const validateQueryResponse = await this.client.indices.validateQuery({
|
|
|
- index: query.index,
|
|
|
- type: query.type,
|
|
|
- explain: true,
|
|
|
- body: {
|
|
|
- query: query.body.query,
|
|
|
- },
|
|
|
- });
|
|
|
+
|
|
|
+ const validateQueryResponse = await (async() => {
|
|
|
+ if (isES7ClientDelegator(this.client)) {
|
|
|
+ const es7SearchQuery = query as ES7SearchQuery;
|
|
|
+ return this.client.indices.validateQuery({
|
|
|
+ explain: true,
|
|
|
+ index: es7SearchQuery.index,
|
|
|
+ body: {
|
|
|
+ query: es7SearchQuery.body?.query,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isES8ClientDelegator(this.client)) {
|
|
|
+ const es8SearchQuery = query as ES8SearchQuery;
|
|
|
+ return this.client.indices.validateQuery({
|
|
|
+ explain: true,
|
|
|
+ index: es8SearchQuery.index,
|
|
|
+ query: es8SearchQuery.body.query,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isES9ClientDelegator(this.client)) {
|
|
|
+ const es9SearchQuery = query as ES9SearchQuery;
|
|
|
+ return this.client.indices.validateQuery({
|
|
|
+ explain: true,
|
|
|
+ index: es9SearchQuery.index,
|
|
|
+ query: es9SearchQuery.body.query,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ throw new Error('Unsupported Elasticsearch version');
|
|
|
+ })();
|
|
|
+
|
|
|
|
|
|
// for debug
|
|
|
logger.debug('ES result: ', validateQueryResponse);
|
|
|
}
|
|
|
|
|
|
- const searchResponse = await this.client.search(query);
|
|
|
+ const searchResponse = await (async() => {
|
|
|
+ if (isES7ClientDelegator(this.client)) {
|
|
|
+ return this.client.search(query as ES7SearchQuery);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isES8ClientDelegator(this.client)) {
|
|
|
+ return this.client.search(query as ES8SearchQuery);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isES9ClientDelegator(this.client)) {
|
|
|
+ const { body, ...rest } = query as ES9SearchQuery;
|
|
|
+ return this.client.search({
|
|
|
+ ...rest,
|
|
|
+ // Elimination of the body property since ES9
|
|
|
+ // https://raw.githubusercontent.com/elastic/elasticsearch-js/2f6200eb397df0e54d23848d769a93614ee1fb45/docs/release-notes/breaking-changes.md
|
|
|
+ query: body.query,
|
|
|
+ sort: body.sort,
|
|
|
+ highlight: body.highlight,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ throw new Error('Unsupported Elasticsearch version');
|
|
|
+ })();
|
|
|
|
|
|
const _total = searchResponse?.hits?.total;
|
|
|
let total = 0;
|
|
|
@@ -597,44 +653,49 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
|
|
|
|
|
|
/**
|
|
|
* create search query for Elasticsearch
|
|
|
- *
|
|
|
- * @param {object | undefined} option optional paramas
|
|
|
* @returns {object} query object
|
|
|
*/
|
|
|
- createSearchQuery(option?) {
|
|
|
- let fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names', 'comments'];
|
|
|
- if (option) {
|
|
|
- fields = option.fields || fields;
|
|
|
- }
|
|
|
+ createSearchQuery(): SearchQuery {
|
|
|
+ const fields = ['path', 'bookmark_count', 'comment_count', 'seenUsers_count', 'updated_at', 'tag_names', 'comments'];
|
|
|
|
|
|
// sort by score
|
|
|
- const query = {
|
|
|
+ const query: SearchQuery = {
|
|
|
index: this.aliasName,
|
|
|
_source: fields,
|
|
|
body: {
|
|
|
- query: {}, // query
|
|
|
+ query: {
|
|
|
+ bool: {},
|
|
|
+ },
|
|
|
},
|
|
|
};
|
|
|
|
|
|
return query;
|
|
|
}
|
|
|
|
|
|
- appendResultSize(query, from?, size?): void {
|
|
|
+ appendResultSize(query: SearchQuery, from?: number, size?: number): void {
|
|
|
query.from = from || DEFAULT_OFFSET;
|
|
|
query.size = size || DEFAULT_LIMIT;
|
|
|
}
|
|
|
|
|
|
- appendSortOrder(query, sortAxis: SORT_AXIS, sortOrder: SORT_ORDER): void {
|
|
|
+ appendSortOrder(query: SearchQuery, sortAxis: SORT_AXIS, sortOrder: SORT_ORDER): void {
|
|
|
+ if (query.body == null) {
|
|
|
+ throw new Error('query.body is not initialized');
|
|
|
+ }
|
|
|
+
|
|
|
// default sort order is score descending
|
|
|
const sort = ES_SORT_AXIS[sortAxis] || ES_SORT_AXIS[RELATION_SCORE];
|
|
|
const order = ES_SORT_ORDER[sortOrder] || ES_SORT_ORDER[DESC];
|
|
|
- query.body.sort = { [sort]: { order } };
|
|
|
+
|
|
|
+ query.body.sort = {
|
|
|
+ [sort]: { order },
|
|
|
+ };
|
|
|
+
|
|
|
}
|
|
|
|
|
|
- initializeBoolQuery(query) {
|
|
|
+ initializeBoolQuery(query: SearchQuery): SearchQuery {
|
|
|
// query is created by createSearchQuery()
|
|
|
- if (!query.body.query.bool) {
|
|
|
- query.body.query.bool = {};
|
|
|
+ if (query?.body?.query?.bool == null) {
|
|
|
+ throw new Error('query.body.query.bool is not initialized');
|
|
|
}
|
|
|
|
|
|
const isInitialized = (query) => { return !!query && Array.isArray(query) };
|
|
|
@@ -651,14 +712,30 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
|
|
|
return query;
|
|
|
}
|
|
|
|
|
|
- appendCriteriaForQueryString(query, parsedKeywords: ESQueryTerms): void {
|
|
|
+ appendCriteriaForQueryString(query: SearchQuery, parsedKeywords: ESQueryTerms): void {
|
|
|
query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
|
|
|
|
|
|
+ if (query.body?.query?.bool == null) {
|
|
|
+ throw new Error('query.body.query.bool is not initialized');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (query.body?.query?.bool.must == null || !Array.isArray(query.body?.query?.bool.must)) {
|
|
|
+ throw new Error('query.body.query.bool.must is not initialized');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (query.body?.query?.bool.must_not == null || !Array.isArray(query.body?.query?.bool.must_not)) {
|
|
|
+ throw new Error('query.body.query.bool.must_not is not initialized');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (query.body?.query?.bool.filter == null || !Array.isArray(query.body?.query?.bool.filter)) {
|
|
|
+ throw new Error('query.body.query.bool.filter is not initialized');
|
|
|
+ }
|
|
|
+
|
|
|
if (parsedKeywords.match.length > 0) {
|
|
|
const q = {
|
|
|
multi_match: {
|
|
|
query: parsedKeywords.match.join(' '),
|
|
|
- type: 'most_fields',
|
|
|
+ type: 'most_fields' as const,
|
|
|
fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en', 'comments.ja', 'comments.en'],
|
|
|
},
|
|
|
};
|
|
|
@@ -670,18 +747,18 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
|
|
|
multi_match: {
|
|
|
query: parsedKeywords.not_match.join(' '),
|
|
|
fields: ['path.ja', 'path.en', 'body.ja', 'body.en', 'comments.ja', 'comments.en'],
|
|
|
- operator: 'or',
|
|
|
+ operator: 'or' as const,
|
|
|
},
|
|
|
};
|
|
|
query.body.query.bool.must_not.push(q);
|
|
|
}
|
|
|
|
|
|
if (parsedKeywords.phrase.length > 0) {
|
|
|
- parsedKeywords.phrase.forEach((phrase) => {
|
|
|
+ for (const phrase of parsedKeywords.phrase) {
|
|
|
const phraseQuery = {
|
|
|
multi_match: {
|
|
|
- query: phrase, // each phrase is quoteted words like "This is GROWI"
|
|
|
- type: 'phrase',
|
|
|
+ query: phrase, // query is created by createSearchQuery()
|
|
|
+ type: 'phrase' as const,
|
|
|
fields: [
|
|
|
// Not use "*.ja" fields here, because we want to analyze (parse) search words
|
|
|
'path.raw^2',
|
|
|
@@ -691,15 +768,15 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
|
|
|
},
|
|
|
};
|
|
|
query.body.query.bool.must.push(phraseQuery);
|
|
|
- });
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
if (parsedKeywords.not_phrase.length > 0) {
|
|
|
- parsedKeywords.not_phrase.forEach((phrase) => {
|
|
|
+ for (const phrase of parsedKeywords.not_phrase) {
|
|
|
const notPhraseQuery = {
|
|
|
multi_match: {
|
|
|
query: phrase, // each phrase is quoteted words
|
|
|
- type: 'phrase',
|
|
|
+ type: 'phrase' as const,
|
|
|
fields: [
|
|
|
// Not use "*.ja" fields here, because we want to analyze (parse) search words
|
|
|
'path.raw^2',
|
|
|
@@ -708,7 +785,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
|
|
|
},
|
|
|
};
|
|
|
query.body.query.bool.must_not.push(notPhraseQuery);
|
|
|
- });
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
if (parsedKeywords.prefix.length > 0) {
|
|
|
@@ -740,12 +817,16 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- async filterPagesByViewer(query, user, userGroups): Promise<void> {
|
|
|
+ async filterPagesByViewer(query: SearchQuery, user, userGroups): Promise<void> {
|
|
|
const showPagesRestrictedByOwner = !configManager.getConfig('security:list-policy:hideRestrictedByOwner');
|
|
|
const showPagesRestrictedByGroup = !configManager.getConfig('security:list-policy:hideRestrictedByGroup');
|
|
|
|
|
|
query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
|
|
|
|
|
|
+ if (query.body?.query?.bool?.filter == null || !Array.isArray(query.body?.query?.bool?.filter)) {
|
|
|
+ throw new Error('query.body.query.bool is not initialized');
|
|
|
+ }
|
|
|
+
|
|
|
const Page = mongoose.model('Page') as unknown as PageModel;
|
|
|
const {
|
|
|
GRANT_PUBLIC, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
|
|
|
@@ -828,7 +909,11 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
|
|
|
};
|
|
|
}
|
|
|
|
|
|
- appendHighlight(query): void {
|
|
|
+ appendHighlight(query: SearchQuery): void {
|
|
|
+ if (query.body == null) {
|
|
|
+ throw new Error('query.body is not initialized');
|
|
|
+ }
|
|
|
+
|
|
|
query.body.highlight = {
|
|
|
fragmenter: 'simple',
|
|
|
pre_tags: ["<em class='highlighted-keyword'>"],
|