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.util;
17  
18  import static org.codelibs.core.stream.StreamUtil.split;
19  import static org.codelibs.core.stream.StreamUtil.stream;
20  
21  import java.util.Map;
22  import java.util.stream.Collectors;
23  
24  import org.codelibs.core.lang.StringUtil;
25  import org.codelibs.fess.Constants;
26  import org.codelibs.fess.entity.SearchRequestParams;
27  import org.codelibs.fess.helper.RelatedQueryHelper;
28  import org.codelibs.fess.mylasta.direction.FessConfig;
29  
30  /**
31   * A utility class for building query strings with proper escaping and parameters.
32   * This class provides methods to construct search queries from various parameters,
33   * handle special characters, and format the final query string for search operations.
34   */
35  public class QueryStringBuilder {
36  
37      private static final String OR_ALT = " || ";
38  
39      private static final String OR = " OR ";
40  
41      private static final String SPACE = " ";
42  
43      private SearchRequestParams params;
44  
45      private boolean escape = false;
46  
47      private String sortField;
48  
49      /**
50       * Default constructor for QueryStringBuilder.
51       * Initializes a new instance with default settings for escape and sortField.
52       */
53      public QueryStringBuilder() {
54          // Default constructor
55      }
56  
57      /**
58       * Quotes a string value if it contains spaces.
59       * Multi-word values are wrapped in double quotes with internal quotes replaced by spaces.
60       *
61       * @param value the string value to quote
62       * @return the quoted string if it contains spaces, otherwise the original value
63       */
64      protected String quote(final String value) {
65          if (value.split("\\s").length > 1) {
66              return new StringBuilder().append('"').append(value.replace('"', ' ')).append('"').toString();
67          }
68          return value;
69      }
70  
71      /**
72       * Escapes special characters in a query string if escaping is enabled.
73       * Replaces reserved characters with their escaped equivalents based on the Constants.RESERVED array.
74       *
75       * @param value the query string to escape
76       * @return the escaped query string, or the original value if escaping is disabled
77       */
78      protected String escapeQuery(final String value) {
79          if (!escape) {
80              return value;
81          }
82  
83          String newValue = value;
84          for (final String element : Constants.RESERVED) {
85              final String replacement = element.replaceAll("(.)", "\\\\$1");
86              newValue = newValue.replace(element, replacement);
87          }
88          return newValue;
89      }
90  
91      /**
92       * Builds the complete query string from the configured parameters.
93       * Combines base query, extra queries, field filters, and sort field into a single query string.
94       *
95       * @return the complete formatted query string
96       */
97      public String build() {
98          final FessConfig fessConfig = ComponentUtil.getFessConfig();
99          final int maxQueryLength = fessConfig.getQueryMaxLengthAsInteger();
100         final StringBuilder queryBuf = new StringBuilder(255);
101 
102         final String query = buildBaseQuery();
103         if (StringUtil.isNotBlank(query)) {
104             queryBuf.append(escapeQuery(query));
105         }
106 
107         stream(params.getExtraQueries())
108                 .of(stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength).forEach(q -> {
109                     appendQuery(queryBuf, q);
110                 }));
111 
112         stream(params.getFields()).of(stream -> stream.forEach(entry -> {
113             final String key = entry.getKey();
114             final String[] values = entry.getValue();
115             if (values == null) {
116                 // nothing
117             } else if (values.length == 1) {
118                 queryBuf.append(' ').append(key).append(":\"").append(values[0]).append('\"');
119             } else if (values.length > 1) {
120                 boolean first = true;
121                 queryBuf.append(" (");
122                 for (final String value : values) {
123                     if (first) {
124                         first = false;
125                     } else {
126                         queryBuf.append(OR);
127                     }
128                     queryBuf.append(key).append(":\"").append(value).append('\"');
129                 }
130                 queryBuf.append(')');
131             }
132         }));
133 
134         final String baseQuery = queryBuf.toString().trim();
135         if (StringUtil.isBlank(sortField)) {
136             return baseQuery;
137         }
138         return baseQuery + " sort:" + sortField;
139     }
140 
141     /**
142      * Appends a query string to the query buffer with proper formatting.
143      * Handles OR operators and wraps complex queries in parentheses when necessary.
144      *
145      * @param queryBuf the StringBuilder to append to
146      * @param query the query string to append
147      */
148     protected void appendQuery(final StringBuilder queryBuf, final String query) {
149         String q = query;
150         for (final String s : ComponentUtil.getFessConfig().getCrawlerDocumentSpaces()) {
151             q = q.replace(s, SPACE);
152         }
153         final boolean exists = q.indexOf(OR) != -1 || q.indexOf(OR_ALT) != -1;
154         queryBuf.append(' ');
155         if (exists) {
156             queryBuf.append('(');
157         }
158         queryBuf.append(query);
159         if (exists) {
160             queryBuf.append(')');
161         }
162     }
163 
164     /**
165      * Builds the base query string from search parameters.
166      * Handles both condition-based queries and simple text queries, including related query expansion.
167      *
168      * @return the base query string
169      */
170     protected String buildBaseQuery() {
171         final StringBuilder queryBuf = new StringBuilder(255);
172         if (params.hasConditionQuery()) {
173             appendConditions(queryBuf, params.getConditions());
174         } else {
175             final String query = params.getQuery();
176             if (StringUtil.isNotBlank(query)) {
177                 if (ComponentUtil.hasRelatedQueryHelper()) {
178                     final RelatedQueryHelper relatedQueryHelper = ComponentUtil.getRelatedQueryHelper();
179                     final String[] relatedQueries = relatedQueryHelper.getRelatedQueries(query);
180                     if (relatedQueries.length == 0) {
181                         appendQuery(queryBuf, query);
182                     } else {
183                         queryBuf.append('(');
184                         queryBuf.append(quote(query));
185                         for (final String s : relatedQueries) {
186                             queryBuf.append(OR);
187                             queryBuf.append(quote(s));
188                         }
189                         queryBuf.append(')');
190                     }
191                 } else {
192                     appendQuery(queryBuf, query);
193                 }
194             }
195         }
196         return queryBuf.toString().trim();
197     }
198 
199     /**
200      * Appends various search conditions to the query buffer.
201      * Processes advanced search parameters like occurrence, phrases, OR queries, NOT queries,
202      * file types, site searches, and timestamp filters.
203      *
204      * @param queryBuf the StringBuilder to append conditions to
205      * @param conditions a map of condition types to their values
206      */
207     protected void appendConditions(final StringBuilder queryBuf, final Map<String, String[]> conditions) {
208         if (conditions == null) {
209             return;
210         }
211         final FessConfig fessConfig = ComponentUtil.getFessConfig();
212         final int maxQueryLength = fessConfig.getQueryMaxLengthAsInteger();
213 
214         stream(conditions.get(SearchRequestParams.AS_OCCURRENCE))
215                 .of(stream -> stream.filter(this::isOccurrence).findFirst().ifPresent(q -> queryBuf.insert(0, q + ":")));
216 
217         stream(conditions.get(SearchRequestParams.AS_Q))
218                 .of(stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength)
219                         .forEach(q -> queryBuf.append(' ').append(q)));
220         stream(conditions.get(SearchRequestParams.AS_EPQ))
221                 .of(stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength)
222                         .forEach(q -> queryBuf.append(" \"").append(escape(q, "\"")).append('"')));
223         stream(conditions.get(SearchRequestParams.AS_OQ)).of(stream -> stream
224                 .filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength)
225                 .forEach(oq -> split(oq, " ")
226                         .get(s -> s.filter(StringUtil::isNotBlank).reduce((q1, q2) -> escape(q1, "(", ")") + OR + escape(q2, "(", ")")))
227                         .ifPresent(q -> {
228                             appendQuery(queryBuf, q);
229                         })));
230         stream(conditions.get(SearchRequestParams.AS_NQ))
231                 .of(stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength).forEach(eq -> {
232                     final String nq =
233                             split(eq, " ").get(s -> s.filter(StringUtil::isNotBlank).map(q -> "NOT " + q).collect(Collectors.joining(" ")));
234                     queryBuf.append(' ').append(nq);
235                 }));
236         stream(conditions.get(SearchRequestParams.AS_FILETYPE))
237                 .of(stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength)
238                         .forEach(q -> queryBuf.append(" filetype:\"").append(q.trim()).append('"')));
239         stream(conditions.get(SearchRequestParams.AS_SITESEARCH))
240                 .of(stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength)
241                         .forEach(q -> queryBuf.append(" site:").append(q.trim())));
242         stream(conditions.get(SearchRequestParams.AS_TIMESTAMP))
243                 .of(stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength)
244                         .forEach(q -> queryBuf.append(" timestamp:").append(q.trim())));
245     }
246 
247     /**
248      * Checks if a value represents an occurrence-based search modifier.
249      * Currently supports "allintitle" and "allinurl" modifiers.
250      *
251      * @param value the value to check
252      * @return true if the value is an occurrence modifier, false otherwise
253      */
254     protected boolean isOccurrence(final String value) {
255         return "allintitle".equals(value) || "allinurl".equals(value);
256     }
257 
258     /**
259      * Escapes specific characters in a query string.
260      * Replaces each specified character with its escaped version (prefixed with backslash).
261      *
262      * @param q the query string to escape
263      * @param values the characters to escape
264      * @return the escaped query string
265      */
266     protected String escape(final String q, final String... values) {
267         String value = q;
268         for (final String s : values) {
269             value = value.replace(s, "\\" + s);
270         }
271         return value;
272     }
273 
274     /**
275      * Sets the search request parameters for this builder.
276      * This method follows the builder pattern for method chaining.
277      *
278      * @param params the search request parameters to use
279      * @return this QueryStringBuilder instance for method chaining
280      */
281     public QueryStringBuilder params(final SearchRequestParams params) {
282         this.params = params;
283         return this;
284     }
285 
286     /**
287      * Sets the sort field for the query.
288      * This method follows the builder pattern for method chaining.
289      *
290      * @param sortField the field name to sort by
291      * @return this QueryStringBuilder instance for method chaining
292      */
293     public QueryStringBuilder sortField(final String sortField) {
294         this.sortField = sortField;
295         return this;
296     }
297 
298     /**
299      * Sets whether to escape special characters in queries.
300      * This method follows the builder pattern for method chaining.
301      *
302      * @param escape true to enable escaping, false to disable
303      * @return this QueryStringBuilder instance for method chaining
304      */
305     public QueryStringBuilder escape(final boolean escape) {
306         this.escape = escape;
307         return this;
308     }
309 }