Просмотр исходного кода

Merge pull request #5657 from weseek/feat/namedquery-filter-by-prefix

feat: namedquery filter by prefix (Architecture Only)
Haku Mizuki 4 лет назад
Родитель
Сommit
3c71d13ada

+ 16 - 4
packages/app/src/server/interfaces/search.ts

@@ -14,22 +14,34 @@ export type QueryTerms = {
   not_tag: string[],
   not_tag: string[],
 }
 }
 
 
-export type ParsedQuery = { queryString: string, terms?: QueryTerms, delegatorName?: string }
+export type ParsedQuery = { queryString: string, terms: QueryTerms, delegatorName?: string }
 
 
 export interface SearchQueryParser {
 export interface SearchQueryParser {
-  parseSearchQuery(queryString: string): Promise<ParsedQuery>
+  parseSearchQuery(queryString: string, nqName: string | null): Promise<ParsedQuery>
 }
 }
 
 
-export interface SearchResolver{
+export interface SearchResolver {
   resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData | null]>
   resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData | null]>
 }
 }
 
 
-export interface SearchDelegator<T = unknown> {
+export interface SearchDelegator<T = unknown, KEY extends AllTermsKey = AllTermsKey, QTERMS = unknown> {
   name?: SearchDelegatorName
   name?: SearchDelegatorName
   search(data: SearchableData | null, user, userGroups, option): Promise<ISearchResult<T>>
   search(data: SearchableData | null, user, userGroups, option): Promise<ISearchResult<T>>
+  isTermsNormalized(terms: Partial<QueryTerms>): terms is QTERMS,
+  validateTerms(terms: QueryTerms): UnavailableTermsKey<KEY>[],
 }
 }
 
 
 export type SearchableData = {
 export type SearchableData = {
   queryString: string
   queryString: string
   terms: QueryTerms
   terms: QueryTerms
 }
 }
+
+// Terms Key types
+export type AllTermsKey = keyof QueryTerms;
+export type UnavailableTermsKey<K extends AllTermsKey> = Exclude<AllTermsKey, K>;
+export type ESTermsKey = 'match' | 'not_match' | 'phrase' | 'not_phrase' | 'prefix' | 'not_prefix' | 'tag' | 'not_tag';
+export type MongoTermsKey = 'match' | 'not_match' | 'prefix' | 'not_prefix';
+
+// Query Terms types
+export type ESQueryTerms = Pick<QueryTerms, ESTermsKey>;
+export type MongoQueryTerms = Pick<QueryTerms, MongoTermsKey>;

+ 3 - 2
packages/app/src/server/routes/search.js

@@ -113,7 +113,7 @@ module.exports = function(crowi, app) {
   api.search = async function(req, res) {
   api.search = async function(req, res) {
     const user = req.user;
     const user = req.user;
     const {
     const {
-      q = null, type = null, sort = null, order = null,
+      q = null, nq = null, type = null, sort = null, order = null,
     } = req.query;
     } = req.query;
     let paginateOpts;
     let paginateOpts;
 
 
@@ -147,7 +147,8 @@ module.exports = function(crowi, app) {
     let delegatorName;
     let delegatorName;
     try {
     try {
       const keyword = decodeURIComponent(q);
       const keyword = decodeURIComponent(q);
-      [searchResult, delegatorName] = await searchService.searchKeyword(keyword, user, userGroups, searchOpts);
+      const nqName = nq ?? decodeURIComponent(nq);
+      [searchResult, delegatorName] = await searchService.searchKeyword(keyword, nqName, user, userGroups, searchOpts);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Failed to search', err);
       logger.error('Failed to search', err);

+ 14 - 2
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -11,7 +11,7 @@ import { createBatchStream } from '../../util/batch-stream';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { PageModel } from '../../models/page';
 import { PageModel } from '../../models/page';
 import {
 import {
-  SearchDelegator, SearchableData, QueryTerms,
+  SearchDelegator, SearchableData, QueryTerms, UnavailableTermsKey, ESQueryTerms, ESTermsKey,
 } from '../../interfaces/search';
 } from '../../interfaces/search';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import {
 import {
@@ -40,7 +40,7 @@ const ES_SORT_ORDER = {
 
 
 type Data = any;
 type Data = any;
 
 
-class ElasticsearchDelegator implements SearchDelegator<Data> {
+class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQueryTerms> {
 
 
   name!: SearchDelegatorName.DEFAULT
   name!: SearchDelegatorName.DEFAULT
 
 
@@ -965,6 +965,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
   async search(data: SearchableData, user, userGroups, option): Promise<ISearchResult<unknown>> {
   async search(data: SearchableData, user, userGroups, option): Promise<ISearchResult<unknown>> {
     const { queryString, terms } = data;
     const { queryString, terms } = data;
 
 
+    if (terms == null) {
+      throw Error('Cannnot process search since terms is undefined.');
+    }
+
     const from = option.offset || null;
     const from = option.offset || null;
     const size = option.limit || null;
     const size = option.limit || null;
     const sort = option.sort || null;
     const sort = option.sort || null;
@@ -984,6 +988,14 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     return this.searchKeyword(query);
     return this.searchKeyword(query);
   }
   }
 
 
+  isTermsNormalized(terms: Partial<QueryTerms>): terms is ESQueryTerms {
+    return true;
+  }
+
+  validateTerms(terms: QueryTerms): UnavailableTermsKey<ESTermsKey>[] {
+    return [];
+  }
+
   async syncPageUpdated(page, user) {
   async syncPageUpdated(page, user) {
     logger.debug('SearchClient.syncPageUpdated', page.path);
     logger.debug('SearchClient.syncPageUpdated', page.path);
 
 

+ 12 - 3
packages/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -4,13 +4,14 @@ import { PageModel, PageDocument } from '~/server/models/page';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { IPage } from '~/interfaces/page';
 import { IPage } from '~/interfaces/page';
 import {
 import {
-  SearchableData, SearchDelegator,
+  QueryTerms, MongoTermsKey,
+  SearchableData, SearchDelegator, UnavailableTermsKey, MongoQueryTerms,
 } from '../../interfaces/search';
 } from '../../interfaces/search';
 import { serializeUserSecurely } from '../../models/serializers/user-serializer';
 import { serializeUserSecurely } from '../../models/serializers/user-serializer';
 import { ISearchResult } from '~/interfaces/search';
 import { ISearchResult } from '~/interfaces/search';
 
 
 
 
-class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
+class PrivateLegacyPagesDelegator implements SearchDelegator<IPage, MongoTermsKey, MongoQueryTerms> {
 
 
   name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES
   name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES
 
 
@@ -18,7 +19,7 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
     this.name = SearchDelegatorName.PRIVATE_LEGACY_PAGES;
     this.name = SearchDelegatorName.PRIVATE_LEGACY_PAGES;
   }
   }
 
 
-  async search(_data: SearchableData | null, user, userGroups, option): Promise<ISearchResult<IPage>> {
+  async search(data: SearchableData | null, user, userGroups, option): Promise<ISearchResult<IPage>> {
     const { offset, limit } = option;
     const { offset, limit } = option;
 
 
     if (offset == null || limit == null) {
     if (offset == null || limit == null) {
@@ -59,6 +60,14 @@ class PrivateLegacyPagesDelegator implements SearchDelegator<IPage> {
     };
     };
   }
   }
 
 
+  isTermsNormalized(terms: Partial<QueryTerms>): terms is MongoQueryTerms {
+    return true;
+  }
+
+  validateTerms(terms: QueryTerms): UnavailableTermsKey<MongoTermsKey>[] {
+    return [];
+  }
+
 }
 }
 
 
 export default PrivateLegacyPagesDelegator;
 export default PrivateLegacyPagesDelegator;

+ 19 - 22
packages/app/src/server/service/search.ts

@@ -74,7 +74,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
   isErrorOccuredOnSearching: boolean | null
   isErrorOccuredOnSearching: boolean | null
 
 
-  fullTextSearchDelegator: any & SearchDelegator
+  fullTextSearchDelegator: any & ElasticsearchDelegator
 
 
   nqDelegators: {[key in SearchDelegatorName]: SearchDelegator}
   nqDelegators: {[key in SearchDelegatorName]: SearchDelegator}
 
 
@@ -124,10 +124,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
     logger.info('No elasticsearch URI is specified so that full text search is disabled.');
     logger.info('No elasticsearch URI is specified so that full text search is disabled.');
   }
   }
 
 
-  generateNQDelegators(defaultDelegator: SearchDelegator): {[key in SearchDelegatorName]: SearchDelegator} {
+  generateNQDelegators(defaultDelegator: ElasticsearchDelegator): {[key in SearchDelegatorName]: SearchDelegator} {
     return {
     return {
       [SearchDelegatorName.DEFAULT]: defaultDelegator,
       [SearchDelegatorName.DEFAULT]: defaultDelegator,
-      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: new PrivateLegacyPagesDelegator(),
+      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: new PrivateLegacyPagesDelegator() as SearchDelegator,
     };
     };
   }
   }
 
 
@@ -218,12 +218,14 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return this.fullTextSearchDelegator.rebuildIndex();
     return this.fullTextSearchDelegator.rebuildIndex();
   }
   }
 
 
-  async parseSearchQuery(_queryString: string): Promise<ParsedQuery> {
+  // TODO: https://redmine.weseek.co.jp/issues/92049 No need to parseNQString anymore
+  async parseSearchQuery(queryString: string, nqName: string | null): Promise<ParsedQuery> {
+    // eslint-disable-next-line no-param-reassign
+    queryString = normalizeQueryString(queryString);
+
     const regexp = new RegExp(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
     const regexp = new RegExp(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
     const replaceRegexp = new RegExp(/\[nq:|\]/g);
     const replaceRegexp = new RegExp(/\[nq:|\]/g);
 
 
-    const queryString = normalizeQueryString(_queryString);
-
     // when Normal Query
     // when Normal Query
     if (!regexp.test(queryString)) {
     if (!regexp.test(queryString)) {
       return { queryString, terms: this.parseQueryString(queryString) };
       return { queryString, terms: this.parseQueryString(queryString) };
@@ -251,35 +253,30 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return parsedQuery;
     return parsedQuery;
   }
   }
 
 
-  async resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData | null]> {
-    const { queryString, terms, delegatorName } = parsedQuery;
-    if (delegatorName != null) {
-      const nqDelegator = this.nqDelegators[delegatorName];
-      if (nqDelegator != null) {
-        return [nqDelegator, null];
-      }
-    }
+  async resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData]> {
+    const { queryString, terms, delegatorName = SearchDelegatorName.DEFAULT } = parsedQuery;
+    const nqDeledator = this.nqDelegators[delegatorName];
 
 
     const data = {
     const data = {
       queryString,
       queryString,
-      terms: terms as QueryTerms,
+      terms,
     };
     };
-    return [this.nqDelegators[SearchDelegatorName.DEFAULT], data];
+    return [nqDeledator, data];
   }
   }
 
 
-  async searchKeyword(keyword: string, user, userGroups, searchOpts): Promise<[ISearchResult<unknown>, string]> {
-    let parsedQuery;
+  async searchKeyword(keyword: string, nqName: string | null, user, userGroups, searchOpts): Promise<[ISearchResult<unknown>, string | null]> {
+    let parsedQuery: ParsedQuery;
     // parse
     // parse
     try {
     try {
-      parsedQuery = await this.parseSearchQuery(keyword);
+      parsedQuery = await this.parseSearchQuery(keyword, nqName);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Error occurred while parseSearchQuery', err);
       logger.error('Error occurred while parseSearchQuery', err);
       throw err;
       throw err;
     }
     }
 
 
-    let delegator;
-    let data;
+    let delegator: SearchDelegator;
+    let data: SearchableData;
     // resolve
     // resolve
     try {
     try {
       [delegator, data] = await this.resolve(parsedQuery);
       [delegator, data] = await this.resolve(parsedQuery);
@@ -289,7 +286,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
       throw err;
       throw err;
     }
     }
 
 
-    return [await delegator.search(data, user, userGroups, searchOpts), delegator.name];
+    return [await delegator.search(data, user, userGroups, searchOpts), delegator.name ?? null];
   }
   }
 
 
   parseQueryString(queryString: string): QueryTerms {
   parseQueryString(queryString: string): QueryTerms {

+ 4 - 0
packages/app/test/integration/service/search/search-service.test.js

@@ -1,3 +1,7 @@
+/*
+ * !! TODO: https://redmine.weseek.co.jp/issues/92050 Fix & adjust test !!
+ */
+
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 import SearchService from '~/server/service/search';
 import SearchService from '~/server/service/search';