001/* 002 * Copyright 2014 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.cms.search.query; 017 018import java.util.Collections; 019import java.util.stream.Collectors; 020import java.util.stream.Stream; 021 022import org.apache.commons.lang3.StringUtils; 023import org.apache.solr.client.solrj.util.ClientUtils; 024 025import org.ametys.runtime.i18n.I18nizableText; 026 027/** 028 * Represents a {@link Query} testing a string field. 029 */ 030public class StringQuery extends AbstractFieldQuery 031{ 032 /** The operator. */ 033 protected Operator _operator; 034 /** The value to test. */ 035 protected String _value; 036 /** The language. */ 037 protected String _language; 038 /** <code>true</code> if the value is already escaped and there is no need to escape again the value during {@link #build() the build of the query}. */ 039 protected boolean _valueAlreadyEscaped; 040 041 /** 042 * Build a StringQuery testing the existence of the field. 043 * @param fieldPath the field path 044 */ 045 public StringQuery(String fieldPath) 046 { 047 this(fieldPath, Operator.EXISTS, null, null); 048 } 049 050 /** 051 * Build a string query. 052 * @param fieldPath the field's path 053 * @param value the value. 054 */ 055 public StringQuery(String fieldPath, String value) 056 { 057 this(fieldPath, value, null); 058 } 059 060 /** 061 * Build a string query. 062 * @param fieldPath the field's path 063 * @param value the value. 064 * @param language the query language (can be null). 065 */ 066 public StringQuery(String fieldPath, String value, String language) 067 { 068 this(fieldPath, Operator.EQ, value, language); 069 } 070 071 /** 072 * Build a string query. 073 * @param fieldPath the field's path 074 * @param op the operator. 075 * @param value the value. 076 * @param language the query language (can be null). 077 */ 078 public StringQuery(String fieldPath, Operator op, String value, String language) 079 { 080 this(fieldPath, op, value, language, false); 081 } 082 083 /** 084 * Build a string query. 085 * @param fieldPath the field's path 086 * @param op the operator. 087 * @param value the value. 088 * @param language the query language (can be null). 089 * @param alreadyEscaped true if the value is already escaped and there is no need to escape again the value during {@link #build() the build of the query}. 090 */ 091 public StringQuery(String fieldPath, Operator op, String value, String language, boolean alreadyEscaped) 092 { 093 super(fieldPath); 094 _operator = op; 095 _value = value; 096 _language = language; 097 _valueAlreadyEscaped = alreadyEscaped; 098 } 099 100 /** 101 * Get the operator. 102 * @return the operator. 103 */ 104 public Operator getOperator() 105 { 106 return _operator; 107 } 108 109 /** 110 * Get the value. 111 * @return the value. 112 */ 113 public String getValue() 114 { 115 return _value; 116 } 117 118 /** 119 * Get the language. 120 * @return the language. 121 */ 122 public String getLanguage() 123 { 124 return _language; 125 } 126 127 @Override 128 public String build() throws QuerySyntaxException 129 { 130 StringBuilder query = new StringBuilder(); 131 132 if (_operator == Operator.EXISTS) 133 { 134 query.append(_fieldPath).append("_s:").append(QueryHelper.EXISTS_VALUE); 135 return query.toString(); 136 } 137 138 checkStringValue(_value); 139 140 String escapedValue = _valueAlreadyEscaped ? _value : escapeStringValue(_value, _operator); 141 142 if (_operator == Operator.NE) 143 { 144 NotQuery.appendNegation(query); 145 } 146 147 query.append(_fieldPath); 148 149 if (_operator == Operator.SEARCH) 150 { 151 // Test query, unstemmed. 152 query.append("_txt"); 153 if (_language != null) 154 { 155 query.append("_").append(_language); 156 } 157 query.append(":(").append(escapedValue).append(')'); 158 } 159 else if (_operator == Operator.SEARCH_STEMMED) 160 { 161 // Full-text query 162 if (_language == null) 163 { 164 throw new IllegalArgumentException("Cannot build a string query with stemming without language"); 165 } 166 query.append("_txt_stemmed_").append(_language).append(":(").append(escapedValue).append(')'); 167 } 168 else if (_operator == Operator.LIKE) 169 { 170 if (_language != null) 171 { 172 query.append("_").append(_language); 173 } 174 // Wildcard query: run a lower-case search. 175 query.append("_s_lower:").append('(').append(escapedValue.toLowerCase()).append(')'); 176 } 177 else 178 { 179 // Strict string comparison (enumerator value, ID, ...) 180 query.append("_s:").append('"').append(escapedValue).append('"'); 181 } 182 183 return query.toString(); 184 } 185 186 @Override 187 public int hashCode() 188 { 189 final int prime = 31; 190 int result = super.hashCode(); 191 result = prime * result + ((_language == null) ? 0 : _language.hashCode()); 192 result = prime * result + ((_operator == null) ? 0 : _operator.hashCode()); 193 result = prime * result + ((_value == null) ? 0 : _value.hashCode()); 194 return result; 195 } 196 197 @Override 198 public boolean equals(Object obj) 199 { 200 if (this == obj) 201 { 202 return true; 203 } 204 if (!super.equals(obj)) 205 { 206 return false; 207 } 208 if (getClass() != obj.getClass()) 209 { 210 return false; 211 } 212 StringQuery other = (StringQuery) obj; 213 if (_language == null) 214 { 215 if (other._language != null) 216 { 217 return false; 218 } 219 } 220 else if (!_language.equals(other._language)) 221 { 222 return false; 223 } 224 if (_operator != other._operator) 225 { 226 return false; 227 } 228 if (_value == null) 229 { 230 if (other._value != null) 231 { 232 return false; 233 } 234 } 235 else if (!_value.equals(other._value)) 236 { 237 return false; 238 } 239 return true; 240 } 241 242 /** 243 * Ensure that the string value is valid (i.e. the parentheses are balanced), 244 * and throw an exception if it isn't. 245 * @param value the string value to check. 246 * @throws QuerySyntaxException if the value is invalid. 247 */ 248 public static void checkStringValue(String value) throws QuerySyntaxException 249 { 250 // The parenthesis nesting level. 251 boolean invalid = false; 252 int level = 0; 253 254 for (int i = 0; i < value.length() && !invalid; i++) 255 { 256 char ch = value.charAt(i); 257 // The current character is escaped if the previous character is a slash 258 // that is not itself escaped (the previous-previous character must not be a slash). 259 boolean escaped = i > 0 && value.charAt(i - 1) == '\\' && !(i > 1 && value.charAt(i - 2) == '\\'); 260 261 if (ch == '(' && !escaped) 262 { 263 level++; 264 } 265 else if (ch == ')' && !escaped) 266 { 267 level--; 268 if (level < 0) 269 { 270 // More closing than opening parentheses at this point: the value is invalid. 271 invalid = true; 272 } 273 } 274 } 275 276 // If the parentheses are balanced, the level is 0 at this point. 277 if (level != 0 || invalid) 278 { 279 String message = "The string search " + value + " is illegal, check the parentheses."; 280 I18nizableText details = new I18nizableText("plugin.cms", "UITOOL_SEARCH_ERROR_QUERY_LABEL", Collections.singletonMap("value", new I18nizableText(value))); 281 282 throw new QuerySyntaxException(message, details); 283 } 284 } 285 286 /** 287 * Escape from a string value the characters that can modify the query field 288 * @param value the string value. 289 * @param operator the operator 290 * @return the escaped value. 291 */ 292 public static String escapeStringValue(String value, Operator operator) 293 { 294 switch (operator) 295 { 296 case LIKE: 297 // '*' are allowed characters 298 // So escape all characters except '*' 299 return Stream.of(StringUtils.splitByWholeSeparatorPreserveAllTokens(value, "*")) 300 .map(ClientUtils::escapeQueryChars) 301 .collect(Collectors.joining("*")); 302 case SEARCH: 303 case SEARCH_STEMMED: 304 // '*' are allowed characters 305 // So escape all characters (except '*' and whitespaces) 306 return _escapeQueryCharsExceptStarsAndWhitespaces(value); 307 308 default: 309 return ClientUtils.escapeQueryChars(value); 310 } 311 } 312 313 @SuppressWarnings("all") 314 private static String _escapeQueryCharsExceptStarsAndWhitespaces(String s) 315 { 316 StringBuilder sb = new StringBuilder(); 317 for (int i = 0; i < s.length(); i++) 318 { 319 char c = s.charAt(i); 320 // These characters are part of the query syntax and must be escaped (except '*' and whitespaces) 321 if (c == '\\' || c == '+' || c == '-' || c == '!' || c == '(' || c == ')' || c == ':' 322 || c == '^' || c == '[' || c == ']' || c == '\"' || c == '{' || c == '}' || c == '~' 323 || c == '?' || c == '|' || c == '&' || c == ';' || c == '/') 324 { 325 sb.append('\\'); 326 } 327 sb.append(c); 328 } 329 return sb.toString(); 330 } 331}