View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.helper;
17  
18  import static org.codelibs.core.stream.StreamUtil.stream;
19  
20  import java.util.ArrayList;
21  import java.util.HashMap;
22  import java.util.List;
23  import java.util.Map;
24  import java.util.Set;
25  import java.util.UUID;
26  import java.util.function.Consumer;
27  
28  import org.apache.logging.log4j.LogManager;
29  import org.apache.logging.log4j.Logger;
30  import org.apache.lucene.search.Query;
31  import org.codelibs.core.lang.StringUtil;
32  import org.codelibs.fess.Constants;
33  import org.codelibs.fess.entity.FacetInfo;
34  import org.codelibs.fess.entity.GeoInfo;
35  import org.codelibs.fess.entity.QueryContext;
36  import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType;
37  import org.codelibs.fess.exception.InvalidQueryException;
38  import org.codelibs.fess.exception.QueryParseException;
39  import org.codelibs.fess.mylasta.action.FessUserBean;
40  import org.codelibs.fess.mylasta.direction.FessConfig;
41  import org.codelibs.fess.query.QueryFieldConfig;
42  import org.codelibs.fess.query.parser.QueryParser;
43  import org.codelibs.fess.score.QueryRescorer;
44  import org.codelibs.fess.util.ComponentUtil;
45  import org.dbflute.optional.OptionalThing;
46  import org.lastaflute.core.message.UserMessages;
47  import org.lastaflute.web.util.LaRequestUtil;
48  import org.opensearch.action.search.SearchRequestBuilder;
49  import org.opensearch.index.query.BoolQueryBuilder;
50  import org.opensearch.index.query.QueryBuilder;
51  import org.opensearch.index.query.QueryBuilders;
52  import org.opensearch.index.query.functionscore.FunctionScoreQueryBuilder.FilterFunctionBuilder;
53  import org.opensearch.index.query.functionscore.ScoreFunctionBuilder;
54  import org.opensearch.index.query.functionscore.ScoreFunctionBuilders;
55  import org.opensearch.search.rescore.RescorerBuilder;
56  import org.opensearch.search.sort.SortBuilder;
57  import org.opensearch.search.sort.SortBuilders;
58  import org.opensearch.search.sort.SortOrder;
59  
60  import jakarta.servlet.http.HttpServletRequest;
61  import jakarta.servlet.http.HttpSession;
62  
63  /**
64   * QueryHelper is responsible for building and managing OpenSearch queries for Fess search functionality.
65   * It handles query construction, role-based access control, boost functions, sorting, and search preferences.
66   * This class serves as the central component for translating user search requests into properly formatted
67   * OpenSearch queries with appropriate filters and scoring mechanisms.
68   */
69  public class QueryHelper {
70  
71      /**
72       * Default constructor.
73       */
74      public QueryHelper() {
75          // Default constructor
76      }
77  
78      /** Logger for this class */
79      private static final Logger logger = LogManager.getLogger(QueryHelper.class);
80  
81      /** Constant used to indicate that query-based preference should be used for search routing */
82      protected static final String PREFERENCE_QUERY = "_query";
83  
84      /** Prefix used to identify sort parameters in search queries */
85      protected String sortPrefix = "sort:";
86  
87      /** Additional query string to be appended to all search queries */
88      protected String additionalQuery;
89  
90      /** Default sort builders to be applied when no explicit sorting is specified */
91      protected SortBuilder<?>[] defaultSortBuilders;
92  
93      /** Prefix used for highlight field names in search results */
94      protected String highlightPrefix = "hl_";
95  
96      /** Default facet information configuration for search results */
97      protected FacetInfo defaultFacetInfo;
98  
99      /** Default geographic information configuration for location-based searches */
100     protected GeoInfo defaultGeoInfo;
101 
102     /** Map containing field-specific boost values for search scoring */
103     protected Map<String, String> fieldBoostMap = new HashMap<>();
104 
105     /** List of boost functions to be applied to search queries for custom scoring */
106     protected List<FilterFunctionBuilder> boostFunctionList = new ArrayList<>();
107 
108     /** List of query rescorers for post-processing search results */
109     protected List<QueryRescorer> queryRescorerList = new ArrayList<>();
110 
111     /**
112      * Builds a complete QueryContext for search operations, applying all necessary filters,
113      * boosts, and role-based access controls.
114      *
115      * @param searchRequestType the type of search request (e.g., regular search, admin search)
116      * @param query the user's search query string
117      * @param context a consumer that allows additional customization of the query context
118      * @return a fully constructed QueryContext ready for OpenSearch execution
119      */
120     public QueryContext build(final SearchRequestType searchRequestType, final String query, final Consumer<QueryContext> context) {
121         String q;
122         if (additionalQuery != null && StringUtil.isNotBlank(query)) {
123             q = query + " " + additionalQuery;
124         } else {
125             q = query;
126         }
127 
128         final QueryContext queryContext = new QueryContext(q, true);
129         buildBaseQuery(queryContext, context);
130         buildBoostQuery(queryContext);
131         buildRoleQuery(queryContext, searchRequestType);
132         buildVirtualHostQuery(queryContext, searchRequestType);
133 
134         if (!queryContext.hasSorts() && defaultSortBuilders != null) {
135             queryContext.addSorts(defaultSortBuilders);
136         }
137         return queryContext;
138     }
139 
140     /**
141      * Builds virtual host query filters to restrict search results to the current virtual host.
142      * This method adds filters based on the virtual host key, except for admin searches.
143      *
144      * @param queryContext the query context to modify
145      * @param searchRequestType the type of search request to determine if virtual host filtering should be applied
146      */
147     protected void buildVirtualHostQuery(final QueryContext queryContext, final SearchRequestType searchRequestType) {
148         switch (searchRequestType) {
149         case ADMIN_SEARCH:
150             // nothing to do
151             break;
152         default:
153             final String key = ComponentUtil.getVirtualHostHelper().getVirtualHostKey();
154             if (StringUtil.isNotBlank(key)) {
155                 queryContext.addQuery(boolQuery -> {
156                     boolQuery.filter(QueryBuilders.termQuery(ComponentUtil.getFessConfig().getIndexFieldVirtualHost(), key));
157                 });
158             }
159             break;
160         }
161     }
162 
163     /**
164      * Builds role-based access control query filters to restrict search results based on user roles.
165      * This method applies role-based filtering to ensure users only see documents they have access to.
166      *
167      * @param queryContext the query context to modify
168      * @param searchRequestType the type of search request to determine role filtering requirements
169      */
170     protected void buildRoleQuery(final QueryContext queryContext, final SearchRequestType searchRequestType) {
171         if (queryContext.roleQueryEnabled()) {
172             final Set<String> roleSet = ComponentUtil.getRoleQueryHelper().build(searchRequestType);
173             if (!roleSet.isEmpty()) {
174                 queryContext.addQuery(boolQuery -> buildRoleQuery(roleSet, boolQuery));
175             }
176         }
177     }
178 
179     /**
180      * Builds role-based query filters using the provided role set.
181      * This method adds should clauses for allowed roles and must-not clauses for denied roles.
182      *
183      * @param roleSet the set of roles to use for filtering
184      * @param boolQuery the boolean query builder to add role filters to
185      */
186     public void buildRoleQuery(final Set<String> roleSet, final BoolQueryBuilder boolQuery) {
187         final BoolQueryBuilder roleQuery = QueryBuilders.boolQuery();
188         final FessConfig fessConfig = ComponentUtil.getFessConfig();
189         final String roleField = fessConfig.getIndexFieldRole();
190         roleSet.stream().forEach(name -> roleQuery.should(QueryBuilders.termQuery(roleField, name)));
191         final String deniedPrefix = fessConfig.getRoleSearchDeniedPrefix();
192         roleSet.stream().forEach(name -> roleQuery.mustNot(QueryBuilders.termQuery(roleField, deniedPrefix + name)));
193         boolQuery.filter(roleQuery);
194     }
195 
196     /**
197      * Builds boost query functions to modify document scoring based on various factors.
198      * This method adds field value factors, key matching boosts, and custom boost functions.
199      *
200      * @param queryContext the query context to add boost functions to
201      */
202     protected void buildBoostQuery(final QueryContext queryContext) {
203         queryContext.addFunctionScore(list -> {
204             list.add(new FilterFunctionBuilder(
205                     ScoreFunctionBuilders.fieldValueFactorFunction(ComponentUtil.getFessConfig().getIndexFieldBoost())));
206             ComponentUtil.getKeyMatchHelper().buildQuery(queryContext.getDefaultKeyword(), list);
207             list.addAll(boostFunctionList);
208         });
209     }
210 
211     /**
212      * Builds the base query from the user's search string using the configured query parser.
213      * This method parses the query string, processes it, and applies any additional customizations.
214      *
215      * @param queryContext the query context containing the query string
216      * @param context a consumer for additional query context customization
217      * @throws InvalidQueryException if the query string cannot be parsed
218      */
219     public void buildBaseQuery(final QueryContext queryContext, final Consumer<QueryContext> context) {
220         try {
221             final Query query = getQueryParser().parse(queryContext.getQueryString());
222             final QueryBuilder queryBuilder = ComponentUtil.getQueryProcessor().execute(queryContext, query, 1.0f);
223             if (queryBuilder != null) {
224                 queryContext.setQueryBuilder(queryBuilder);
225             } else {
226                 queryContext.setQueryBuilder(QueryBuilders.matchAllQuery());
227             }
228             // TODO options query
229             context.accept(queryContext);
230         } catch (final QueryParseException e) {
231             throw new InvalidQueryException(messages -> messages.addErrorsInvalidQueryParseError(UserMessages.GLOBAL_PROPERTY_KEY),
232                     "Invalid query: " + queryContext.getQueryString(), e);
233         }
234     }
235 
236     /**
237      * Gets the query parser instance for parsing search query strings.
238      *
239      * @return the configured query parser
240      */
241     protected QueryParser getQueryParser() {
242         return ComponentUtil.getQueryParser();
243     }
244 
245     /**
246      * Processes and sets search preferences for routing search requests to appropriate OpenSearch shards.
247      * This method determines the preference value based on user roles, session information, or request parameters.
248      *
249      * @param searchRequestBuilder the search request builder to configure
250      * @param userBean the optional user bean containing user information
251      * @param query the search query string
252      */
253     public void processSearchPreference(final SearchRequestBuilder searchRequestBuilder, final OptionalThing<FessUserBean> userBean,
254             final String query) {
255         userBean.map(user -> {
256             if (user.hasRoles(ComponentUtil.getFessConfig().getAuthenticationAdminRolesAsArray())) {
257                 return Constants.SEARCH_PREFERENCE_LOCAL;
258             }
259             return user.getUserId();
260         }).ifPresent(p -> searchRequestBuilder.setPreference(p)).orElse(() -> LaRequestUtil.getOptionalRequest().map(r -> {
261             final HttpSession session = r.getSession(false);
262             if (session != null) {
263                 return session.getId();
264             }
265             final String preference = r.getParameter("preference");
266             if (preference != null) {
267                 return Integer.toString(preference.hashCode());
268             }
269             final Object accessType = r.getAttribute(Constants.SEARCH_LOG_ACCESS_TYPE);
270             if (Constants.SEARCH_LOG_ACCESS_TYPE_JSON.equals(accessType)) {
271                 return processJsonSearchPreference(r, query);
272             }
273             if (Constants.SEARCH_LOG_ACCESS_TYPE_GSA.equals(accessType)) {
274                 return processGsaSearchPreference(r, query);
275             }
276             return null;
277         }).ifPresent(p -> searchRequestBuilder.setPreference(p)));
278     }
279 
280     /**
281      * Processes search preferences specifically for JSON API requests.
282      * This method determines the preference value based on configuration and query content.
283      *
284      * @param req the HTTP servlet request
285      * @param query the search query string
286      * @return the preference value for JSON search requests, or null if not applicable
287      */
288     protected String processJsonSearchPreference(final HttpServletRequest req, final String query) {
289         final String pref = ComponentUtil.getFessConfig().getQueryJsonDefaultPreference();
290         if (PREFERENCE_QUERY.equals(pref)) {
291             return Integer.toString(query.hashCode());
292         }
293         if (StringUtil.isNotBlank(pref)) {
294             return pref;
295         }
296         return null;
297     }
298 
299     /**
300      * Processes search preferences specifically for GSA (Google Search Appliance) compatible requests.
301      * This method determines the preference value based on configuration and query content.
302      *
303      * @param req the HTTP servlet request
304      * @param query the search query string
305      * @return the preference value for GSA search requests, or null if not applicable
306      */
307     protected String processGsaSearchPreference(final HttpServletRequest req, final String query) {
308         final String pref = ComponentUtil.getFessConfig().getQueryGsaDefaultPreference();
309         if (PREFERENCE_QUERY.equals(pref)) {
310             return Integer.toString(query.hashCode());
311         }
312         if (StringUtil.isNotBlank(pref)) {
313             return pref;
314         }
315         return null;
316     }
317 
318     /**
319      * Gets the sort prefix used for identifying sort parameters in search queries.
320      *
321      * @return the sortPrefix
322      */
323     public String getSortPrefix() {
324         return sortPrefix;
325     }
326 
327     /**
328      * Sets the sort prefix used for identifying sort parameters in search queries.
329      *
330      * @param sortPrefix the sortPrefix to set
331      */
332     public void setSortPrefix(final String sortPrefix) {
333         this.sortPrefix = sortPrefix;
334     }
335 
336     /**
337      * Gets the additional query string that is appended to all search queries.
338      *
339      * @return the additionalQuery
340      */
341     public String getAdditionalQuery() {
342         return additionalQuery;
343     }
344 
345     /**
346      * Sets the additional query string that is appended to all search queries.
347      *
348      * @param additionalQuery the additionalQuery to set
349      */
350     public void setAdditionalQuery(final String additionalQuery) {
351         this.additionalQuery = additionalQuery;
352     }
353 
354     /**
355      * Adds a default sort configuration to be applied when no explicit sorting is specified.
356      * This method appends the new sort to existing default sorts.
357      *
358      * @param fieldName the field name to sort by
359      * @param order the sort order ("ASC" or "DESC")
360      */
361     public void addDefaultSort(final String fieldName, final String order) {
362         final List<SortBuilder<?>> list = new ArrayList<>();
363         if (defaultSortBuilders != null) {
364             stream(defaultSortBuilders).of(stream -> stream.forEach(builder -> list.add(builder)));
365         }
366         list.add(createFieldSortBuilder(fieldName, SortOrder.DESC.toString().equalsIgnoreCase(order) ? SortOrder.DESC : SortOrder.ASC));
367         defaultSortBuilders = list.toArray(new SortBuilder[list.size()]);
368     }
369 
370     /**
371      * Creates a sort builder for the specified field and order.
372      * This method handles special cases for score fields and regular field sorting.
373      *
374      * @param field the field name to sort by
375      * @param order the sort order (ASC or DESC)
376      * @return a configured sort builder
377      */
378     protected SortBuilder<?> createFieldSortBuilder(final String field, final SortOrder order) {
379         if (QueryFieldConfig.SCORE_FIELD.equals(field) || QueryFieldConfig.DOC_SCORE_FIELD.equals(field)) {
380             return SortBuilders.scoreSort().order(order);
381         }
382         return SortBuilders.fieldSort(field).order(order);
383     }
384 
385     /**
386      * Sets the prefix used for highlight field names in search results.
387      *
388      * @param highlightPrefix the prefix string to use for highlight fields
389      */
390     public void setHighlightPrefix(final String highlightPrefix) {
391         this.highlightPrefix = highlightPrefix;
392     }
393 
394     /**
395      * Gets the current highlight prefix used for highlight field names.
396      *
397      * @return the current highlight prefix
398      */
399     public String getHighlightPrefix() {
400         return highlightPrefix;
401     }
402 
403     /**
404      * Gets the default facet information configuration.
405      *
406      * @return the default facet information, or null if not configured
407      */
408     public FacetInfo getDefaultFacetInfo() {
409         return defaultFacetInfo;
410     }
411 
412     /**
413      * Sets the default facet information configuration for search results.
414      *
415      * @param defaultFacetInfo the facet information to use as default
416      */
417     public void setDefaultFacetInfo(final FacetInfo defaultFacetInfo) {
418         this.defaultFacetInfo = defaultFacetInfo;
419     }
420 
421     /**
422      * Gets the default geographic information configuration.
423      *
424      * @return the default geographic information, or null if not configured
425      */
426     public GeoInfo getDefaultGeoInfo() {
427         return defaultGeoInfo;
428     }
429 
430     /**
431      * Sets the default geographic information configuration for location-based searches.
432      *
433      * @param defaultGeoInfo the geographic information to use as default
434      */
435     public void setDefaultGeoInfo(final GeoInfo defaultGeoInfo) {
436         this.defaultGeoInfo = defaultGeoInfo;
437     }
438 
439     /**
440      * Generates a unique identifier string by creating a UUID and removing hyphens.
441      *
442      * @return a unique identifier string
443      */
444     public String generateId() {
445         return UUID.randomUUID().toString().replace("-", StringUtil.EMPTY);
446     }
447 
448     /**
449      * Adds a boost function to modify document scoring during search.
450      * This method adds a boost function that applies to all documents.
451      *
452      * @param scoreFunction the score function to add for boosting
453      */
454     public void addBoostFunction(final ScoreFunctionBuilder<?> scoreFunction) {
455         boostFunctionList.add(new FilterFunctionBuilder(scoreFunction));
456     }
457 
458     /**
459      * Adds a boost function with a filter to modify document scoring during search.
460      * This method adds a boost function that applies only to documents matching the filter.
461      *
462      * @param filter the query filter to determine which documents the boost applies to
463      * @param scoreFunction the score function to add for boosting
464      */
465     public void addBoostFunction(final QueryBuilder filter, final ScoreFunctionBuilder<?> scoreFunction) {
466         boostFunctionList.add(new FilterFunctionBuilder(filter, scoreFunction));
467     }
468 
469     /**
470      * Gets an array of rescorer builders for post-processing search results.
471      * This method evaluates all configured query rescorers with the provided parameters.
472      *
473      * @param params parameters to pass to the rescorers during evaluation
474      * @return an array of rescorer builders, filtered to exclude null values
475      */
476     public RescorerBuilder<?>[] getRescorers(final Map<String, Object> params) {
477         return queryRescorerList.stream().map(r -> r.evaluate(params)).filter(b -> b != null).toArray(n -> new RescorerBuilder<?>[n]);
478     }
479 
480     /**
481      * Adds a query rescorer for post-processing search results.
482      * Query rescorers allow modification of search scores after the initial query execution.
483      *
484      * @param rescorer the query rescorer to add
485      */
486     public void addQueryRescorer(final QueryRescorer rescorer) {
487         queryRescorerList.add(rescorer);
488     }
489 }