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

Merge pull request #4749 from weseek/feat/impl-custom-delegator-and-initialization

feat: Impl custom delegator and initialization
Yuki Takei 4 лет назад
Родитель
Сommit
c55ff3494f

+ 1 - 1
packages/app/src/interfaces/page.ts

@@ -14,7 +14,7 @@ export type IPage = {
   createdAt: Date,
   updatedAt: Date,
   seenUsers: Ref<IUser>[],
-  parent: Ref<IPage>,
+  parent: Ref<IPage> | null,
   isEmpty: boolean,
   redirectTo: string,
   grant: number,

+ 1 - 1
packages/app/src/server/crowi/index.js

@@ -18,6 +18,7 @@ import { projectRoot } from '~/utils/project-dir-utils';
 import ConfigManager from '../service/config-manager';
 import AppService from '../service/app';
 import AclService from '../service/acl';
+import SearchService from '../service/search';
 import AttachmentService from '../service/attachment';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
@@ -370,7 +371,6 @@ Crowi.prototype.setupPassport = async function() {
 };
 
 Crowi.prototype.setupSearcher = async function() {
-  const SearchService = require('~/server/service/search');
   this.searchService = new SearchService(this);
 };
 

+ 1 - 1
packages/app/src/server/interfaces/search.ts

@@ -33,7 +33,7 @@ export type Result<T> = {
 }
 
 export type MetaData = {
-  meta: { [key:string]: any }
+  meta?: { [key:string]: any }
 }
 
 export type SearchableData = {

+ 1 - 1
packages/app/src/server/middlewares/auto-reconnect-to-search.js

@@ -14,7 +14,7 @@ module.exports = (crowi) => {
       await searchService.reconnectClient();
     }
     catch (err) {
-      logger.error('Auto reconnection failed.');
+      logger.error('Auto reconnection failed.', err);
     }
 
     return searchService.isReachable;

+ 20 - 0
packages/app/src/server/models/obsolete-page.js

@@ -219,6 +219,26 @@ export class PageQueryBuilder {
     return this;
   }
 
+  addConditionAsNonRootPage() {
+    this.query = this.query.and({ path: { $ne: '/' } });
+
+    return this;
+  }
+
+  addConditionAsNotMigrated() {
+    this.query = this.query
+      .and({ parent: null });
+
+    return this;
+  }
+
+  addConditionAsMigrated() {
+    this.query = this.query
+      .and({ parent: { $ne: null } });
+
+    return this;
+  }
+
   /*
    * Add this condition when get any ancestor pages including the target's parent
    */

+ 53 - 0
packages/app/src/server/service/search-delegator/private-legacy-pages.ts

@@ -0,0 +1,53 @@
+import mongoose from 'mongoose';
+
+import { PageModel, PageDocument } from '~/server/models/page';
+import { SearchDelegatorName } from '~/interfaces/named-query';
+import { IPage } from '~/interfaces/page';
+import {
+  MetaData, Result, SearchableData, SearchDelegator,
+} from '../../interfaces/search';
+
+
+type Data = {
+  pages: IPage[]
+}
+
+class PrivateLegacyPagesDelegator implements SearchDelegator<Data> {
+
+  name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES
+
+  async search(data: SearchableData | null, user, userGroups, option): Promise<Result<Data> & MetaData> {
+    const { offset, limit } = option;
+
+    if (offset == null || limit == null) {
+      throw Error('PrivateLegacyPagesDelegator requires pagination options (offset, limit).');
+    }
+    if (user == null && userGroups == null) {
+      throw Error('Either of user and userGroups must not be null.');
+    }
+
+    // find private legacy pages
+    const Page = mongoose.model('Page') as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    const queryBuilder = new PageQueryBuilder(Page.find());
+
+    const pages: PageDocument[] = await queryBuilder
+      .addConditionAsNonRootPage()
+      .addConditionAsNotMigrated()
+      .addConditionToFilteringByViewer(user, userGroups)
+      .addConditionToPagenate(offset, limit)
+      .query
+      .lean()
+      .exec();
+
+    return {
+      data: {
+        pages,
+      },
+    };
+  }
+
+}
+
+export default PrivateLegacyPagesDelegator;

+ 17 - 7
packages/app/src/server/service/search.ts

@@ -1,11 +1,14 @@
 import mongoose from 'mongoose';
 import RE2 from 're2';
 
-import { NamedQueryModel } from '../models/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
+
+import { NamedQueryModel } from '../models/named-query';
 import {
   SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, Result, MetaData, SearchableData, QueryTerms,
 } from '../interfaces/search';
+import ElasticsearchDelegator from './search-delegator/elasticsearch';
+import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
 
 import loggerFactory from '~/utils/logger';
 
@@ -31,7 +34,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
   fullTextSearchDelegator: any & SearchDelegator
 
-  nqDelegators: {[key in SearchDelegatorName]: SearchDelegator} // TODO: initialize
+  nqDelegators: {[key in SearchDelegatorName]: SearchDelegator}
 
   constructor(crowi) {
     this.crowi = crowi;
@@ -41,7 +44,9 @@ class SearchService implements SearchQueryParser, SearchResolver {
     this.isErrorOccuredOnSearching = null;
 
     try {
-      this.fullTextSearchDelegator = this.generateDelegator();
+      this.fullTextSearchDelegator = this.generateFullTextSearchDelegator();
+      this.nqDelegators = this.generateNQDelegators(this.fullTextSearchDelegator);
+      logger.info('Succeeded to initialize search delegators');
     }
     catch (err) {
       logger.error(err);
@@ -66,11 +71,10 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return uri != null && uri.length > 0;
   }
 
-  generateDelegator() {
+  generateFullTextSearchDelegator() {
     logger.info('Initializing search delegator');
 
     if (this.isElasticsearchEnabled) {
-      const ElasticsearchDelegator = require('./search-delegator/elasticsearch');
       logger.info('Elasticsearch is enabled');
       return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
     }
@@ -78,6 +82,13 @@ class SearchService implements SearchQueryParser, SearchResolver {
     logger.info('No elasticsearch URI is specified so that full text search is disabled.');
   }
 
+  generateNQDelegators(defaultDelegator: SearchDelegator): {[key in SearchDelegatorName]: SearchDelegator} {
+    return {
+      [SearchDelegatorName.DEFAULT]: defaultDelegator,
+      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: new PrivateLegacyPagesDelegator(),
+    };
+  }
+
   registerUpdateEvent() {
     const pageEvent = this.crowi.event('page');
     pageEvent.on('create', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
@@ -154,7 +165,6 @@ class SearchService implements SearchQueryParser, SearchResolver {
   }
 
   async parseSearchQuery(_queryString: string): Promise<ParsedQuery> {
-
     const regexp = new RE2(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
     const replaceRegexp = new RE2(/\[nq:|\]/g);
 
@@ -202,7 +212,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
       queryString,
       terms: terms as QueryTerms,
     };
-    return [this.fullTextSearchDelegator, data];
+    return [this.nqDelegators[SearchDelegatorName.DEFAULT], data];
   }
 
   async searchKeyword(keyword: string, user, userGroups, searchOpts): Promise<Result<any> & MetaData> {

+ 85 - 20
packages/app/src/test/integration/service/search/search-service.test.js

@@ -1,3 +1,5 @@
+import mongoose from 'mongoose';
+
 import SearchService from '~/server/service/search';
 import NamedQuery from '~/server/models/named-query';
 
@@ -8,16 +10,16 @@ describe('SearchService test', () => {
   let searchService;
 
   const DEFAULT = 'FullTextSearch';
+  const PRIVATE_LEGACY_PAGES = 'PrivateLegacyPages';
 
   // let NamedQuery;
 
   let dummyAliasOf;
-  let dummyDelegatorName;
 
   let namedQuery1;
   let namedQuery2;
 
-  const dummyDelegator = {
+  const dummyFullTextSearchDelegator = {
     search() {
       return;
     },
@@ -27,18 +29,29 @@ describe('SearchService test', () => {
     crowi = await getInstance();
     searchService = new SearchService(crowi);
     searchService.nqDelegators = {
-      FullTextSearch: dummyDelegator,
+      ...searchService.nqDelegators,
+      [DEFAULT]: dummyFullTextSearchDelegator, // override with dummy full-text search delegator
     };
+
+    dummyAliasOf = 'match -notmatch "phrase" -"notphrase" prefix:/pre1 -prefix:/pre2 tag:Tag1 -tag:Tag2';
+
+    await NamedQuery.insertMany([
+      { name: 'named_query1', delegatorName: PRIVATE_LEGACY_PAGES },
+      { name: 'named_query2', aliasOf: dummyAliasOf },
+    ]);
+
+    namedQuery1 = await NamedQuery.findOne({ name: 'named_query1' });
+    namedQuery2 = await NamedQuery.findOne({ name: 'named_query2' });
   });
 
 
   describe('parseQueryString()', () => {
     test('should parse queryString', async() => {
-      const queryString = 'match -notmatch "phrase" -"notphrase" [nq:named_query] prefix:/pre1 -prefix:/pre2 tag:Tag1 -tag:Tag2';
+      const queryString = 'match -notmatch "phrase" -"notphrase" prefix:/pre1 -prefix:/pre2 tag:Tag1 -tag:Tag2';
       const terms = await searchService.parseQueryString(queryString);
 
       const expected = { // QueryTerms
-        match: ['match', '[nq:named_query]'],
+        match: ['match'],
         not_match: ['notmatch'],
         phrase: ['"phrase"'],
         not_phrase: ['"notphrase"'],
@@ -53,18 +66,6 @@ describe('SearchService test', () => {
   });
 
   describe('parseSearchQuery()', () => {
-    beforeAll(async() => {
-      dummyDelegatorName = DEFAULT;
-      dummyAliasOf = 'match -notmatch "phrase" -"notphrase" prefix:/pre1 -prefix:/pre2 tag:Tag1 -tag:Tag2';
-
-      await NamedQuery.insertMany([
-        { name: 'named_query1', delegatorName: dummyDelegatorName },
-        { name: 'named_query2', aliasOf: dummyAliasOf },
-      ]);
-
-      namedQuery1 = await NamedQuery.findOne({ name: 'named_query1' });
-      namedQuery2 = await NamedQuery.findOne({ name: 'named_query2' });
-    });
 
     test('should return result with delegatorName', async() => {
       const queryString = '[nq:named_query1]';
@@ -72,7 +73,7 @@ describe('SearchService test', () => {
 
       const expected = {
         queryString,
-        delegatorName: dummyDelegatorName,
+        delegatorName: PRIVATE_LEGACY_PAGES,
       };
 
       expect(parsedQuery).toStrictEqual(expected);
@@ -97,7 +98,9 @@ describe('SearchService test', () => {
 
       expect(parsedQuery).toStrictEqual(expected);
     });
+  });
 
+  describe('resolve()', () => {
     test('should resolve as full-text search delegator', async() => {
       const parsedQuery = {
         queryString: dummyAliasOf,
@@ -118,14 +121,14 @@ describe('SearchService test', () => {
       const expectedData = parsedQuery;
 
       expect(data).toStrictEqual(expectedData);
-      // expect(typeof delegator.search).toBe('function'); TODO: enable test after implementing delegator initialization
+      expect(typeof delegator.search).toBe('function');
     });
 
     test('should resolve as custom search delegator', async() => {
       const queryString = '[nq:named_query1]';
       const parsedQuery = {
         queryString,
-        delegatorName: dummyDelegatorName,
+        delegatorName: PRIVATE_LEGACY_PAGES,
       };
 
       const [delegator, data] = await searchService.resolve(parsedQuery);
@@ -135,7 +138,69 @@ describe('SearchService test', () => {
       expect(data).toBe(expectedData);
       expect(typeof delegator.search).toBe('function');
     });
+  });
+
+  describe('searchKeyword()', () => {
+    test('should search with custom search delegator', async() => {
+      const Page = mongoose.model('Page');
+      const User = mongoose.model('User');
+      await User.insertMany([
+        { name: 'dummyuser1', username: 'dummyuser1', email: 'dummyuser1@example.com' },
+        { name: 'dummyuser2', username: 'dummyuser2', email: 'dummyuser2@example.com' },
+      ]);
 
+      const testUser1 = await User.findOne({ username: 'dummyuser1' });
+      const testUser2 = await User.findOne({ username: 'dummyuser2' });
+
+      await Page.insertMany([
+        {
+          path: '/user1',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+        },
+        {
+          path: '/user1_owner',
+          grant: Page.GRANT_OWNER,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+          grantedUsers: [testUser1._id],
+        },
+        {
+          path: '/user2_public',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser2,
+          lastUpdateUser: testUser2,
+        },
+      ]);
+
+      const page1 = await Page.findOne({ path: '/user1' });
+
+      await Page.insertMany([
+        {
+          path: '/user1/hasParent',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+          parent: page1,
+        },
+      ]);
+
+      const queryString = '[nq:named_query1]';
+      const parsedQuery = {
+        queryString,
+        delegatorName: PRIVATE_LEGACY_PAGES,
+      };
+
+      const [delegator, data] = await searchService.resolve(parsedQuery);
+
+      const result = await delegator.search(data, testUser1, null, { limit: 0, offset: 0 });
+
+      const resultPaths = result.data.pages.map(page => page.path);
+      const flag = resultPaths.includes('/user1') && resultPaths.includes('/user1_owner') && resultPaths.includes('/user2_public');
+
+      expect(flag).toBe(true);
+    });
   });
 
 });