001/*
002 *  Copyright 2021 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.Objects;
020import java.util.stream.Collectors;
021import java.util.stream.Stream;
022
023import org.apache.commons.lang3.StringUtils;
024import org.apache.solr.client.solrj.util.ClientUtils;
025
026import org.ametys.runtime.i18n.I18nizableText;
027
028/**
029 * Represents a {@link Query} testing a text field.
030 */
031public abstract class AbstractTextQuery extends AbstractOperatorQuery<String>
032{
033    /** The language. */
034    protected String _language;
035    /** <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}. */
036    protected boolean _valueAlreadyEscaped;
037    
038    /**
039     * Build a AbstractTextQuery testing the existence of the field.
040     * @param fieldPath the field path
041     */
042    public AbstractTextQuery(String fieldPath)
043    {
044        this(fieldPath, Operator.EXISTS, null, null);
045    }
046    
047    /**
048     * Build a text query.
049     * @param fieldPath the field's path
050     * @param value the value.
051     */
052    public AbstractTextQuery(String fieldPath, String value)
053    {
054        this(fieldPath, value, null);
055    }
056    
057    /**
058     * Build a text query.
059     * @param fieldPath the field's path
060     * @param value the value.
061     * @param language the query language (can be null).
062     */
063    public AbstractTextQuery(String fieldPath, String value, String language)
064    {
065        this(fieldPath, Operator.EQ, value, language);
066    }
067    
068    /**
069     * Build a text query.
070     * @param fieldPath the field's path
071     * @param op the operator.
072     * @param value the value.
073     * @param language the query language (can be null).
074     */
075    public AbstractTextQuery(String fieldPath, Operator op, String value, String language)
076    {
077        this(fieldPath, op, value, language, false);
078    }
079    
080    /**
081     * Build a text query.
082     * @param fieldPath the field's path
083     * @param op the operator.
084     * @param value the value.
085     * @param language the query language (can be null).
086     * @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}.
087     */
088    public AbstractTextQuery(String fieldPath, Operator op, String value, String language, boolean alreadyEscaped)
089    {
090        super(fieldPath, op, value);
091        _language = language;
092        _valueAlreadyEscaped = alreadyEscaped;
093    }
094    
095    /**
096     * Get the language.
097     * @return the language.
098     */
099    public String getLanguage()
100    {
101        return _language;
102    }
103    
104    @Override
105    public String build() throws QuerySyntaxException
106    {
107        Operator operator = getOperator();
108        String value = getValue();
109        
110        StringBuilder query = new StringBuilder();
111                
112        checkStringValue(value);
113        
114        String escapedValue = _valueAlreadyEscaped ? value : escapeStringValue(value, operator);
115        
116        if (operator == Operator.NE)
117        {
118            NotQuery.appendNegation(query);
119        }
120        
121        query.append(getFieldName());
122        
123        if (operator == Operator.SEARCH)
124        {
125            // Test query, unstemmed.
126            query.append("_txt");
127            if (_language != null)
128            {
129                query.append("_").append(_language);
130            }
131            query.append(":(").append(escapedValue).append(')');
132        }
133        else if (operator == Operator.SEARCH_STEMMED)
134        {
135            // Full-text query
136            if (_language == null)
137            {
138                throw new IllegalArgumentException("Cannot build a string query with stemming without language");
139            }
140            query.append("_txt_stemmed_").append(_language).append(":(").append(escapedValue).append(')');
141        }
142        else if (operator == Operator.LIKE)
143        {
144            if (_language != null)
145            {
146                query.append("_").append(_language);
147            }
148            // Wildcard query: run a lower-case search.
149            query.append("_s_lower:").append('(').append(escapedValue.toLowerCase()).append(')');
150        }
151        else if (operator == Operator.FUZZY)
152        {
153            if (_language != null)
154            {
155                query.append("_").append(_language);
156            }
157            // Run a lower-case fuzzy search with a maximum edit distance of 2 characters.
158            query.append("_s_lower:").append('(').append(escapedValue.toLowerCase()).append("~2").append(')');
159        }
160        else
161        {
162            // Strict string comparison (enumerator value, ID, ...)
163            query.append("_s:").append('"').append(escapedValue).append('"');
164        }
165        
166        return query.toString();
167    }
168    
169    @Override
170    public int hashCode()
171    {
172        return 31 * super.hashCode() + Objects.hash(_language, _valueAlreadyEscaped);
173    }
174
175    @Override
176    public boolean equals(Object obj)
177    {
178        if (!super.equals(obj))
179        {
180            return false;
181        }
182
183        AbstractTextQuery other = (AbstractTextQuery) obj;
184        return Objects.equals(_language, other._language) && Objects.equals(_valueAlreadyEscaped, other._valueAlreadyEscaped);
185    }
186
187    /**
188     * Ensure that the string value is valid (i.e. the parentheses are balanced),
189     * and throw an exception if it isn't.
190     * @param value the string value to check.
191     * @throws QuerySyntaxException if the value is invalid.
192     */
193    public static void checkStringValue(String value) throws QuerySyntaxException
194    {
195        // The parenthesis nesting level.
196        boolean invalid = false;
197        int level = 0;
198        
199        for (int i = 0; i < value.length() && !invalid; i++)
200        {
201            char ch = value.charAt(i);
202            // The current character is escaped if the previous character is a slash
203            // that is not itself escaped (the previous-previous character must not be a slash).
204            boolean escaped = i > 0 && value.charAt(i - 1) == '\\' && !(i > 1 && value.charAt(i - 2) == '\\');
205            
206            if (ch == '(' && !escaped)
207            {
208                level++;
209            }
210            else if (ch == ')' && !escaped)
211            {
212                level--;
213                if (level < 0)
214                {
215                    // More closing than opening parentheses at this point: the value is invalid.
216                    invalid = true;
217                }
218            }
219        }
220        
221        // If the parentheses are balanced, the level is 0 at this point.
222        if (level != 0 || invalid)
223        {
224            String message = "The string search " + value + " is illegal, check the parentheses.";
225            I18nizableText details = new I18nizableText("plugin.cms", "UITOOL_SEARCH_ERROR_QUERY_LABEL", Collections.singletonMap("value", new I18nizableText(value)));
226            
227            throw new QuerySyntaxException(message, details);
228        }
229    }
230    
231    /**
232     * Escape from a string value the characters that can modify the query field
233     * @param value the string value.
234     * @param operator the operator
235     * @return the escaped value.
236     */
237    public static String escapeStringValue(String value, Operator operator)
238    {
239        switch (operator)
240        {
241            case LIKE:
242                // '*' are allowed characters
243                // So escape all characters except '*'
244                return Stream.of(StringUtils.splitByWholeSeparatorPreserveAllTokens(value, "*"))
245                             .map(ClientUtils::escapeQueryChars)
246                             .collect(Collectors.joining("*"));
247            case SEARCH:
248            case SEARCH_STEMMED:
249                // '*' are allowed characters
250                // So escape all characters (except '*' and whitespaces)
251                return QueryHelper.escapeQueryCharsExceptStarsAndWhitespaces(value);
252
253            default:
254                return ClientUtils.escapeQueryChars(value);
255        }
256    }
257}