001/*
002 *  Copyright 2018 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.advanced;
017
018import java.util.List;
019import java.util.Map;
020import java.util.Objects;
021import java.util.function.BiFunction;
022import java.util.function.Function;
023import java.util.stream.Collectors;
024
025import org.apache.avalon.framework.component.Component;
026import org.apache.commons.lang3.StringUtils;
027import org.apache.solr.client.solrj.util.ClientUtils;
028
029import org.ametys.cms.search.QueryBuilder;
030import org.ametys.cms.search.query.NotQuery;
031import org.ametys.cms.search.query.Query;
032import org.ametys.cms.search.query.Query.LogicalOperator;
033import org.ametys.cms.search.query.Query.Operator;
034import org.ametys.runtime.plugin.component.AbstractLogEnabled;
035
036/**
037 * Creates a {@link AbstractTreeNode} from a JSON object, typically got from client-side AdvancedSearchFormPanel.
038 */
039public class TreeMaker extends AbstractLogEnabled implements Component
040{
041    /** The component role. */
042    public static final String ROLE = TreeMaker.class.getName();
043    
044    /**
045     * Creates a {@link AbstractTreeNode} from JSON values
046     * @param <T> the type of the values of the leaves of the tree to create
047     * @param values the JSON values
048     * @param leafValueMaker The function to create the value of a {@link TreeLeaf}
049     * @return the created {@link AbstractTreeNode} from JSON values
050     */
051    public <T> AbstractTreeNode<T> create(Map<String, Object> values, Function<ClientSideCriterionWrapper, T> leafValueMaker)
052    {
053        return _create(values, leafValueMaker);
054    }
055    
056    private <T> AbstractTreeNode<T> _create(Map<String, Object> values, Function<ClientSideCriterionWrapper, T> leafValueMaker)
057    {
058        String type = (String) values.get("type");
059
060        if ("criterion".equals(type))
061        {
062            // Criterion node: create a leaf
063            String id = (String) values.get("id");
064            Object value = values.get("value");
065            String op = (String) values.get("op");
066
067            ClientSideCriterionWrapper clientSideCriterionWrapper = new ClientSideCriterionWrapper(id, value, op, values);
068            return new TreeLeaf<>(leafValueMaker.apply(clientSideCriterionWrapper));
069        }
070        else if ("and".equalsIgnoreCase(type) || "or".equalsIgnoreCase(type))
071        {
072            // AND/OR node: create an internal node
073            @SuppressWarnings("unchecked")
074            List<Map<String, Object>> expressions = (List<Map<String, Object>>) values.get("expressions");
075            List<AbstractTreeNode<T>> children = expressions.stream()
076                .map(exp -> _create(exp, leafValueMaker))
077                .filter(Objects::nonNull)
078                .collect(Collectors.toList());
079            
080            return new TreeInternalNode<>(children, "and".equalsIgnoreCase(type) ? LogicalOperator.AND : LogicalOperator.OR);
081        }
082        else if (values.isEmpty())
083        {
084            return null;
085        }
086        else
087        {
088            throw new IllegalArgumentException("The object 'values' has an incorrect format (" + values + ")");
089        }
090    }
091    
092    /**
093     * Gets the query given the parameters.
094     * <br>If the provided operator as string represents a real server-side {@link Operator}, then it is just the provided {@link BiFunction} which is applied.
095     * <br>Otherwise, the operator is a client-side one, and it will be transformed by modifying the value (by adding wildcards for instance), and deducting the server-side {@link Operator}.
096     * Then, the {@link BiFunction} is applied with the transformed value and the deducted {@link Operator}.
097     * @param <V> The type of {@link WrappedValue}
098     * @param value The value
099     * @param stringOperator The operator as string
100     * @param toQuery A function applied to a transformed value 
101     * (not necessarily the one given by the method, as it can be transformed by adding wildcard chars, escaping...) 
102     * and a real {@link Operator} (deducted from the given client-side string operator)
103     * @param language The language
104     * @param contextualParameters The contextual parameters
105     * @return The result query
106     */
107    // TODO have those fake client-side operators as real server-side operators
108    public <V extends WrappedValue> Query toQuery(V value, String stringOperator, BiFunction<V, Operator, Query> toQuery, String language, Map<String, Object> contextualParameters)
109    {
110        Query query = null;
111        String wdValue;
112        
113        // Special wildcard searches.
114        switch (stringOperator)
115        {
116            case "is-empty":
117            case "is-not-empty":
118                query = toQuery.apply(value, Operator.EXISTS);
119                if ("is-empty".equals(stringOperator) && query != null)
120                {
121                    query = new NotQuery(query);
122                }
123                break;
124            case "contains":
125            case "not-contains":
126                wdValue = "*" + escapeQueryCharsForLikeQuery((String) value.getValue(), contextualParameters) + "*";
127                value.setValue(wdValue);
128                query = toQuery.apply(value, Operator.LIKE);
129                if ("not-contains".equals(stringOperator) && query != null)
130                {
131                    query = new NotQuery(query);
132                }
133                break;
134            case "starts-with":
135            case "not-starts-with":
136                wdValue = escapeQueryCharsForLikeQuery((String) value.getValue(), contextualParameters) + "*";
137                value.setValue(wdValue);
138                query = toQuery.apply(value, Operator.LIKE);
139                if ("not-starts-with".equals(stringOperator) && query != null)
140                {
141                    query = new NotQuery(query);
142                }
143                break;
144            case "ends-with":
145            case "not-ends-with":
146                wdValue = "*" + escapeQueryCharsForLikeQuery((String) value.getValue(), contextualParameters);
147                value.setValue(wdValue);
148                query = toQuery.apply(value, Operator.LIKE);
149                if ("not-ends-with".equals(stringOperator) && query != null)
150                {
151                    query = new NotQuery(query);
152                }
153                break;
154            case "search":
155            case "searchStemmed":
156                if (StringUtils.isNotBlank(stringOperator))
157                {
158                    wdValue = "*" + StringUtils.strip(((String) value.getValue()).trim(), "*") + "*";
159                    value.setValue(wdValue);                    
160                    Operator operator = Operator.fromName(stringOperator);
161                    query = toQuery.apply(value, operator);
162                }
163                break;
164            default:
165                if (StringUtils.isNotBlank(stringOperator))
166                {
167                    Operator operator = Operator.fromName(stringOperator);
168                    query = toQuery.apply(value, operator);
169                }
170        }
171        
172        return query;
173    }
174    
175    /**
176     * Escape special Solr query characters of the user input, before building the query in order to append '*' character(s)
177     * @param string The string to escape.
178     * @param contextualParameters the search contextual parameters.
179     * @return The escaped string.
180     */
181    protected static String escapeQueryCharsForLikeQuery(String string, Map<String, Object> contextualParameters)
182    {
183        contextualParameters.put(QueryBuilder.VALUE_IS_ESCAPED, Boolean.TRUE);
184        return ClientUtils.escapeQueryChars(string);
185    }
186    
187    /**
188     * A wrapper of a client-side criterion
189     */
190    public static class ClientSideCriterionWrapper
191    {
192        private String _id;
193        private Object _value;
194        private String _strOp;
195        private Map<String, Object> _otherProperties;
196
197        ClientSideCriterionWrapper(String id, Object value, String op, Map<String, Object> otherProperties)
198        {
199            _id = id;
200            _value = value;
201            _strOp = op;
202            _otherProperties = otherProperties;
203        }
204
205        /**
206         * Gets the id
207         * @return the id
208         */
209        public String getId()
210        {
211            return _id;
212        }
213
214        /**
215         * Gets the value
216         * @return the value
217         */
218        public Object getValue()
219        {
220            return _value;
221        }
222
223        /**
224         * Gets the string operator
225         * @return the string operator
226         */
227        public String getStringOperator()
228        {
229            return _strOp;
230        }
231
232        /**
233         * Gets the other properties
234         * @return the other properties
235         */
236        public Map<String, Object> getOtherProperties()
237        {
238            return _otherProperties;
239        }
240    }
241}