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}