search.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import mongoose from 'mongoose';
  2. import RE2 from 're2';
  3. import { SearchDelegatorName } from '~/interfaces/named-query';
  4. import { NamedQueryModel } from '../models/named-query';
  5. import {
  6. SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, Result, MetaData, SearchableData, QueryTerms,
  7. } from '../interfaces/search';
  8. import ElasticsearchDelegator from './search-delegator/elasticsearch';
  9. import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
  10. import loggerFactory from '~/utils/logger';
  11. // eslint-disable-next-line no-unused-vars
  12. const logger = loggerFactory('growi:service:search');
  13. const normalizeQueryString = (_queryString: string): string => {
  14. let queryString = _queryString.trim();
  15. queryString = queryString.replace(/\s+/g, ' ');
  16. return queryString;
  17. };
  18. class SearchService implements SearchQueryParser, SearchResolver {
  19. crowi!: any
  20. configManager!: any
  21. isErrorOccuredOnHealthcheck: boolean | null
  22. isErrorOccuredOnSearching: boolean | null
  23. fullTextSearchDelegator: any & SearchDelegator
  24. nqDelegators: {[key in SearchDelegatorName]: SearchDelegator}
  25. constructor(crowi) {
  26. this.crowi = crowi;
  27. this.configManager = crowi.configManager;
  28. this.isErrorOccuredOnHealthcheck = null;
  29. this.isErrorOccuredOnSearching = null;
  30. try {
  31. this.fullTextSearchDelegator = this.generateFullTextSearchDelegator();
  32. this.nqDelegators = this.generateNQDelegators(this.fullTextSearchDelegator);
  33. logger.info('Succeeded to initialize search delegators');
  34. }
  35. catch (err) {
  36. logger.error(err);
  37. }
  38. if (this.isConfigured) {
  39. this.fullTextSearchDelegator.init();
  40. this.registerUpdateEvent();
  41. }
  42. }
  43. get isConfigured() {
  44. return this.fullTextSearchDelegator != null;
  45. }
  46. get isReachable() {
  47. return this.isConfigured && !this.isErrorOccuredOnHealthcheck && !this.isErrorOccuredOnSearching;
  48. }
  49. get isElasticsearchEnabled() {
  50. const uri = this.configManager.getConfig('crowi', 'app:elasticsearchUri');
  51. return uri != null && uri.length > 0;
  52. }
  53. generateFullTextSearchDelegator() {
  54. logger.info('Initializing search delegator');
  55. if (this.isElasticsearchEnabled) {
  56. logger.info('Elasticsearch is enabled');
  57. return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
  58. }
  59. logger.info('No elasticsearch URI is specified so that full text search is disabled.');
  60. }
  61. generateNQDelegators(defaultDelegator: SearchDelegator): {[key in SearchDelegatorName]: SearchDelegator} {
  62. return {
  63. [SearchDelegatorName.DEFAULT]: defaultDelegator,
  64. [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: new PrivateLegacyPagesDelegator(),
  65. };
  66. }
  67. registerUpdateEvent() {
  68. const pageEvent = this.crowi.event('page');
  69. pageEvent.on('create', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
  70. pageEvent.on('update', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
  71. pageEvent.on('deleteCompletely', this.fullTextSearchDelegator.syncPagesDeletedCompletely.bind(this.fullTextSearchDelegator));
  72. pageEvent.on('delete', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
  73. pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));
  74. pageEvent.on('syncDescendants', this.fullTextSearchDelegator.syncDescendantsPagesUpdated.bind(this.fullTextSearchDelegator));
  75. const bookmarkEvent = this.crowi.event('bookmark');
  76. bookmarkEvent.on('create', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
  77. bookmarkEvent.on('delete', this.fullTextSearchDelegator.syncBookmarkChanged.bind(this.fullTextSearchDelegator));
  78. const tagEvent = this.crowi.event('tag');
  79. tagEvent.on('update', this.fullTextSearchDelegator.syncTagChanged.bind(this.fullTextSearchDelegator));
  80. const commentEvent = this.crowi.event('comment');
  81. commentEvent.on('create', this.delegator.syncCommentChanged.bind(this.delegator));
  82. commentEvent.on('update', this.delegator.syncCommentChanged.bind(this.delegator));
  83. commentEvent.on('delete', this.delegator.syncCommentChanged.bind(this.delegator));
  84. }
  85. resetErrorStatus() {
  86. this.isErrorOccuredOnHealthcheck = false;
  87. this.isErrorOccuredOnSearching = false;
  88. }
  89. async reconnectClient() {
  90. logger.info('Try to reconnect...');
  91. this.fullTextSearchDelegator.initClient();
  92. try {
  93. await this.getInfoForHealth();
  94. logger.info('Reconnecting succeeded.');
  95. this.resetErrorStatus();
  96. }
  97. catch (err) {
  98. throw err;
  99. }
  100. }
  101. async getInfo() {
  102. try {
  103. return await this.fullTextSearchDelegator.getInfo();
  104. }
  105. catch (err) {
  106. logger.error(err);
  107. throw err;
  108. }
  109. }
  110. async getInfoForHealth() {
  111. try {
  112. const result = await this.fullTextSearchDelegator.getInfoForHealth();
  113. this.isErrorOccuredOnHealthcheck = false;
  114. return result;
  115. }
  116. catch (err) {
  117. logger.error(err);
  118. // switch error flag, `isErrorOccuredOnHealthcheck` to be `false`
  119. this.isErrorOccuredOnHealthcheck = true;
  120. throw err;
  121. }
  122. }
  123. async getInfoForAdmin() {
  124. return this.fullTextSearchDelegator.getInfoForAdmin();
  125. }
  126. async normalizeIndices() {
  127. return this.fullTextSearchDelegator.normalizeIndices();
  128. }
  129. async rebuildIndex() {
  130. return this.fullTextSearchDelegator.rebuildIndex();
  131. }
  132. async parseSearchQuery(_queryString: string): Promise<ParsedQuery> {
  133. const regexp = new RE2(/^\[nq:.+\]$/g); // https://regex101.com/r/FzDUvT/1
  134. const replaceRegexp = new RE2(/\[nq:|\]/g);
  135. const queryString = normalizeQueryString(_queryString);
  136. // when Normal Query
  137. if (!regexp.test(queryString)) {
  138. return { queryString, terms: this.parseQueryString(queryString) };
  139. }
  140. // when Named Query
  141. const NamedQuery = mongoose.model('NamedQuery') as NamedQueryModel;
  142. const name = queryString.replace(replaceRegexp, '');
  143. const nq = await NamedQuery.findOne({ name });
  144. // will delegate to full-text search
  145. if (nq == null) {
  146. return { queryString, terms: this.parseQueryString(queryString) };
  147. }
  148. const { aliasOf, delegatorName } = nq;
  149. let parsedQuery;
  150. if (aliasOf != null) {
  151. parsedQuery = { queryString: normalizeQueryString(aliasOf), terms: this.parseQueryString(aliasOf) };
  152. }
  153. if (delegatorName != null) {
  154. parsedQuery = { queryString, delegatorName };
  155. }
  156. return parsedQuery;
  157. }
  158. async resolve(parsedQuery: ParsedQuery): Promise<[SearchDelegator, SearchableData | null]> {
  159. const { queryString, terms, delegatorName } = parsedQuery;
  160. if (delegatorName != null) {
  161. const nqDelegator = this.nqDelegators[delegatorName];
  162. if (nqDelegator != null) {
  163. return [nqDelegator, null];
  164. }
  165. }
  166. const data = {
  167. queryString,
  168. terms: terms as QueryTerms,
  169. };
  170. return [this.nqDelegators[SearchDelegatorName.DEFAULT], data];
  171. }
  172. async searchKeyword(keyword: string, user, userGroups, searchOpts): Promise<Result<any> & MetaData> {
  173. let parsedQuery;
  174. // parse
  175. try {
  176. parsedQuery = await this.parseSearchQuery(keyword);
  177. }
  178. catch (err) {
  179. logger.error('Error occurred while parseSearchQuery', err);
  180. throw err;
  181. }
  182. let delegator;
  183. let data;
  184. // resolve
  185. try {
  186. [delegator, data] = await this.resolve(parsedQuery);
  187. }
  188. catch (err) {
  189. logger.error('Error occurred while resolving search delegator', err);
  190. throw err;
  191. }
  192. return delegator.search(data, user, userGroups, searchOpts);
  193. }
  194. parseQueryString(queryString: string): QueryTerms {
  195. // terms
  196. const matchWords: string[] = [];
  197. const notMatchWords: string[] = [];
  198. const phraseWords: string[] = [];
  199. const notPhraseWords: string[] = [];
  200. const prefixPaths: string[] = [];
  201. const notPrefixPaths: string[] = [];
  202. const tags: string[] = [];
  203. const notTags: string[] = [];
  204. // First: Parse phrase keywords
  205. const phraseRegExp = new RegExp(/(-?"[^"]+")/g);
  206. const phrases = queryString.match(phraseRegExp);
  207. if (phrases !== null) {
  208. queryString = queryString.replace(phraseRegExp, ''); // eslint-disable-line no-param-reassign
  209. phrases.forEach((phrase) => {
  210. phrase.trim();
  211. if (phrase.match(/^-/)) {
  212. notPhraseWords.push(phrase.replace(/^-/, ''));
  213. }
  214. else {
  215. phraseWords.push(phrase);
  216. }
  217. });
  218. }
  219. // Second: Parse other keywords (include minus keywords)
  220. queryString.split(' ').forEach((word) => {
  221. if (word === '') {
  222. return;
  223. }
  224. // https://regex101.com/r/pN9XfK/1
  225. const matchNegative = word.match(/^-(prefix:|tag:)?(.+)$/);
  226. // https://regex101.com/r/3qw9FQ/1
  227. const matchPositive = word.match(/^(prefix:|tag:)?(.+)$/);
  228. if (matchNegative != null) {
  229. if (matchNegative[1] === 'prefix:') {
  230. notPrefixPaths.push(matchNegative[2]);
  231. }
  232. else if (matchNegative[1] === 'tag:') {
  233. notTags.push(matchNegative[2]);
  234. }
  235. else {
  236. notMatchWords.push(matchNegative[2]);
  237. }
  238. }
  239. else if (matchPositive != null) {
  240. if (matchPositive[1] === 'prefix:') {
  241. prefixPaths.push(matchPositive[2]);
  242. }
  243. else if (matchPositive[1] === 'tag:') {
  244. tags.push(matchPositive[2]);
  245. }
  246. else {
  247. matchWords.push(matchPositive[2]);
  248. }
  249. }
  250. });
  251. const terms = {
  252. match: matchWords,
  253. not_match: notMatchWords,
  254. phrase: phraseWords,
  255. not_phrase: notPhraseWords,
  256. prefix: prefixPaths,
  257. not_prefix: notPrefixPaths,
  258. tag: tags,
  259. not_tag: notTags,
  260. };
  261. return terms;
  262. }
  263. }
  264. export default SearchService;