001/*
002 * (C) Copyright 2018-2019 Nuxeo (http://nuxeo.com/) and others.
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 *
016 * Contributors:
017 *     Nelson Silva <nsilva@nuxeo.com>
018 */
019package org.nuxeo.ecm.automation.core.util;
020
021import static org.nuxeo.common.utils.DateUtils.formatISODateTime;
022import static org.nuxeo.common.utils.DateUtils.nowIfNull;
023import static org.nuxeo.ecm.platform.query.api.PageProviderService.NAMED_PARAMETERS;
024
025import java.io.IOException;
026import java.io.Serializable;
027import java.util.ArrayList;
028import java.util.HashMap;
029import java.util.List;
030import java.util.Map;
031import java.util.stream.Collectors;
032import java.util.stream.StreamSupport;
033
034import javax.el.ELContext;
035import javax.el.ValueExpression;
036import javax.validation.constraints.NotNull;
037
038import org.apache.commons.lang3.ArrayUtils;
039import org.apache.commons.lang3.StringUtils;
040import org.apache.logging.log4j.LogManager;
041import org.apache.logging.log4j.Logger;
042import org.nuxeo.ecm.core.api.CoreSession;
043import org.nuxeo.ecm.core.api.DocumentModel;
044import org.nuxeo.ecm.core.api.NuxeoException;
045import org.nuxeo.ecm.core.api.SortInfo;
046import org.nuxeo.ecm.core.api.impl.SimpleDocumentModel;
047import org.nuxeo.ecm.core.api.model.PropertyNotFoundException;
048import org.nuxeo.ecm.core.schema.SchemaManager;
049import org.nuxeo.ecm.core.schema.types.Type;
050import org.nuxeo.ecm.core.schema.types.primitives.IntegerType;
051import org.nuxeo.ecm.core.schema.types.primitives.LongType;
052import org.nuxeo.ecm.platform.actions.ELActionContext;
053import org.nuxeo.ecm.platform.el.ELService;
054import org.nuxeo.ecm.platform.query.api.Aggregate;
055import org.nuxeo.ecm.platform.query.api.Bucket;
056import org.nuxeo.ecm.platform.query.api.PageProvider;
057import org.nuxeo.ecm.platform.query.api.PageProviderDefinition;
058import org.nuxeo.ecm.platform.query.api.PageProviderService;
059import org.nuxeo.ecm.platform.query.api.QuickFilter;
060import org.nuxeo.ecm.platform.query.api.WhereClauseDefinition;
061import org.nuxeo.ecm.platform.query.core.BucketRange;
062import org.nuxeo.ecm.platform.query.core.BucketRangeDate;
063import org.nuxeo.ecm.platform.query.core.BucketTerm;
064import org.nuxeo.ecm.platform.query.core.CoreQueryPageProviderDescriptor;
065import org.nuxeo.ecm.platform.query.core.GenericPageProviderDescriptor;
066import org.nuxeo.ecm.platform.query.nxql.CoreQueryAndFetchPageProvider;
067import org.nuxeo.ecm.platform.query.nxql.CoreQueryDocumentPageProvider;
068import org.nuxeo.ecm.platform.query.nxql.NXQLQueryBuilder;
069import org.nuxeo.runtime.api.Framework;
070
071import com.fasterxml.jackson.databind.JsonNode;
072import com.fasterxml.jackson.databind.ObjectMapper;
073
074/**
075 * @since 10.3
076 */
077public class PageProviderHelper {
078
079    private static final Logger log = LogManager.getLogger(PageProviderHelper.class);
080
081    static final class QueryAndFetchProviderDescriptor extends GenericPageProviderDescriptor {
082        @SuppressWarnings("unchecked")
083        public QueryAndFetchProviderDescriptor() {
084            super();
085            try {
086                klass = (Class<PageProvider<?>>) Class.forName(CoreQueryAndFetchPageProvider.class.getName());
087            } catch (ClassNotFoundException e) {
088                // ignore
089            }
090        }
091    }
092
093    public static final String ASC = "ASC";
094
095    public static final String DESC = "DESC";
096
097    public static final String CURRENT_USERID_PATTERN = "$currentUser";
098
099    public static final String CURRENT_REPO_PATTERN = "$currentRepository";
100
101    protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
102
103    public static PageProviderDefinition getQueryAndFetchProviderDefinition(String query) {
104        return getQueryAndFetchProviderDefinition(query, null);
105    }
106
107    public static PageProviderDefinition getQueryAndFetchProviderDefinition(String query,
108            Map<String, String> properties) {
109        QueryAndFetchProviderDescriptor desc = new QueryAndFetchProviderDescriptor();
110        desc.setName(StringUtils.EMPTY);
111        desc.setPattern(query);
112        if (properties != null) {
113            // set the maxResults to avoid slowing down queries
114            desc.getProperties().putAll(properties);
115        }
116        return desc;
117    }
118
119    public static PageProviderDefinition getQueryPageProviderDefinition(String query) {
120        return getQueryPageProviderDefinition(query, null);
121    }
122
123    public static PageProviderDefinition getQueryPageProviderDefinition(String query, Map<String, String> properties) {
124        return getQueryPageProviderDefinition(query, properties, true, true);
125    }
126
127    /**
128     * @since 11.1
129     */
130    public static PageProviderDefinition getQueryPageProviderDefinition(String query, Map<String, String> properties,
131                                                                        boolean escapeParameters, boolean quoteParameters) {
132        CoreQueryPageProviderDescriptor desc = new CoreQueryPageProviderDescriptor();
133        desc.setName(StringUtils.EMPTY);
134        desc.setPattern(query);
135        desc.setEscapePatternParameters(escapeParameters);
136        desc.setQuotePatternParameters(quoteParameters);
137        if (properties != null) {
138            // set the maxResults to avoid slowing down queries
139            desc.getProperties().putAll(properties);
140        }
141        return desc;
142    }
143
144    public static PageProviderDefinition getPageProviderDefinition(String providerName) {
145        PageProviderService pageProviderService = Framework.getService(PageProviderService.class);
146        return pageProviderService.getPageProviderDefinition(providerName);
147    }
148
149    public static PageProvider<?> getPageProvider(CoreSession session, PageProviderDefinition def,
150            Map<String, String> namedParameters, Object... queryParams) {
151        return getPageProvider(session, def, namedParameters, null, null, null, null, queryParams);
152    }
153
154    public static PageProvider<?> getPageProvider(CoreSession session, PageProviderDefinition def,
155            Map<String, String> namedParameters, List<String> sortBy, List<String> sortOrder, Long pageSize,
156            Long currentPageIndex, Object... queryParams) {
157        return getPageProvider(session, def, namedParameters, sortBy, sortOrder, pageSize, currentPageIndex, null, null,
158                queryParams);
159    }
160
161    public static PageProvider<?> getPageProvider(CoreSession session, PageProviderDefinition def,
162            Map<String, String> namedParameters, List<String> sortBy, List<String> sortOrder, Long pageSize,
163            Long currentPageIndex, List<String> highlights, List<String> quickFilters, Object... parameters) {
164        return getPageProvider(session, def, namedParameters, sortBy, sortOrder, pageSize, currentPageIndex, null,
165                highlights, quickFilters, parameters);
166    }
167
168    public static PageProvider<?> getPageProvider(CoreSession session, PageProviderDefinition def,
169            Map<String, String> namedParameters, List<String> sortBy, List<String> sortOrder, Long pageSize,
170            Long currentPageIndex, Long currentOffset, List<String> highlights, List<String> quickFilters,
171            Object... parameters) {
172        // Ordered parameters
173        if (ArrayUtils.isNotEmpty(parameters)) {
174            // expand specific parameters
175            for (int idx = 0; idx < parameters.length; idx++) {
176                String value = (String) parameters[idx];
177                if (value.equals(CURRENT_USERID_PATTERN)) {
178                    parameters[idx] = session.getPrincipal().getName();
179                } else if (value.equals(CURRENT_REPO_PATTERN)) {
180                    parameters[idx] = session.getRepositoryName();
181                }
182            }
183        }
184
185        // Sort Info Management
186        List<SortInfo> sortInfos = null;
187        if (sortBy != null) {
188            sortInfos = new ArrayList<>();
189            for (int i = 0; i < sortBy.size(); i++) {
190                String sort = sortBy.get(i);
191                if (StringUtils.isNotBlank(sort)) {
192                    boolean sortAscending = (sortOrder != null && !sortOrder.isEmpty()
193                            && ASC.equalsIgnoreCase(sortOrder.get(i).toLowerCase()));
194                    sortInfos.add(new SortInfo(sort, sortAscending));
195                }
196            }
197        }
198
199        // Quick filters management
200        List<QuickFilter> quickFilterList = null;
201        if (quickFilters != null) {
202            quickFilterList = new ArrayList<>();
203            for (String filter : quickFilters) {
204                for (QuickFilter quickFilter : def.getQuickFilters()) {
205                    if (quickFilter.getName().equals(filter)) {
206                        quickFilterList.add(quickFilter);
207                        break;
208                    }
209                }
210            }
211        }
212
213        Map<String, Serializable> props = new HashMap<>();
214        props.put(CoreQueryDocumentPageProvider.CORE_SESSION_PROPERTY, (Serializable) session);
215        DocumentModel searchDocumentModel = getSearchDocumentModel(session, def.getName(), namedParameters);
216
217        PageProviderService pageProviderService = Framework.getService(PageProviderService.class);
218
219        return pageProviderService.getPageProvider(def.getName(), def, searchDocumentModel, sortInfos, pageSize,
220                currentPageIndex, currentOffset, props, highlights, quickFilterList, parameters);
221    }
222
223    public static DocumentModel getSearchDocumentModel(CoreSession session, String providerName,
224            Map<String, String> namedParameters) {
225        PageProviderService pageProviderService = Framework.getService(PageProviderService.class);
226        return getSearchDocumentModel(session, pageProviderService, providerName, namedParameters);
227    }
228
229    /**
230     * Returns a {@link DocumentModel searchDocumentModel} if the given {@code providerName} is not null and has a valid
231     * {@link PageProviderDefinition definition}, or if the given {@code namedParameters} is not empty.
232     * <p>
233     * {@link PageProviderDefinition Definition} is valid if either it has a type or if it declares where clause.
234     *
235     * @since 11.1
236     */
237    public static DocumentModel getSearchDocumentModel(CoreSession session, PageProviderService pps,
238            String providerName, Map<String, String> namedParameters) {
239        // generate search document model if type specified on the definition
240        DocumentModel searchDocumentModel = null;
241        if (StringUtils.isNotBlank(providerName)) {
242            PageProviderDefinition def = pps.getPageProviderDefinition(providerName);
243            if (def != null) {
244                String searchDocType = def.getSearchDocumentType();
245                if (searchDocType != null) {
246                    searchDocumentModel = session.createDocumentModel(searchDocType);
247                } else if (def.getWhereClause() != null) {
248                    // avoid later error on null search doc, in case where clause is only referring to named parameters
249                    // (and no namedParameters are given)
250                    searchDocumentModel = SimpleDocumentModel.empty();
251                }
252            } else {
253                log.error("No page provider definition found for provider: {}", providerName);
254            }
255        }
256
257        if (namedParameters != null && !namedParameters.isEmpty()) {
258            // fall back on simple document if no type defined on page provider
259            if (searchDocumentModel == null) {
260                searchDocumentModel = SimpleDocumentModel.empty();
261            }
262            fillSearchDocument(session, searchDocumentModel, namedParameters);
263        }
264        return searchDocumentModel;
265    }
266
267    /**
268     * @since 11.1
269     */
270    protected static void fillSearchDocument(CoreSession session, @NotNull DocumentModel searchDoc,
271            @NotNull Map<String, String> namedParameters) {
272        // we might search on secured properties
273        Framework.doPrivileged(() -> {
274            for (Map.Entry<String, String> entry : namedParameters.entrySet()) {
275                String key = entry.getKey();
276                String value = entry.getValue();
277                try {
278                    DocumentHelper.setProperty(session, searchDoc, key, value, true);
279                } catch (PropertyNotFoundException | IOException e) {
280                    // assume this is a "pure" named parameter, not part of the search doc schema
281                }
282            }
283            searchDoc.putContextData(NAMED_PARAMETERS, (Serializable) namedParameters);
284        });
285    }
286
287    public static String buildQueryString(PageProvider<?> provider) {
288        return buildQueryStringWithPageProvider(provider, false);
289    }
290
291    public static String buildQueryStringWithAggregates(PageProvider<?> provider) {
292        return buildQueryStringWithPageProvider(provider, provider.hasAggregateSupport());
293    }
294
295    protected static String buildQueryStringWithPageProvider(PageProvider<?> provider, boolean useAggregates) {
296        String quickFiltersClause = "";
297        List<QuickFilter> quickFilters = provider.getQuickFilters();
298        if (quickFilters != null) {
299            for (QuickFilter quickFilter : quickFilters) {
300                String clause = quickFilter.getClause();
301                if (!quickFiltersClause.isEmpty() && clause != null) {
302                    quickFiltersClause = NXQLQueryBuilder.appendClause(quickFiltersClause, clause);
303                } else {
304                    quickFiltersClause = StringUtils.defaultString(clause);
305                }
306            }
307        }
308
309        String aggregatesClause = useAggregates ? buildAggregatesClause(provider) : null;
310
311        PageProviderDefinition def = provider.getDefinition();
312        WhereClauseDefinition whereClause = def.getWhereClause();
313        DocumentModel searchDocumentModel = provider.getSearchDocumentModel();
314        Object[] parameters = provider.getParameters();
315        String query;
316        if (whereClause == null) {
317            String pattern = def.getPattern();
318            if (!quickFiltersClause.isEmpty()) {
319                pattern = appendToPattern(pattern, quickFiltersClause);
320            }
321            if (StringUtils.isNotEmpty(aggregatesClause)) {
322                pattern = appendToPattern(pattern, aggregatesClause);
323            }
324
325            query = NXQLQueryBuilder.getQuery(pattern, parameters, def.getQuotePatternParameters(),
326                    def.getEscapePatternParameters(), searchDocumentModel, null);
327        } else {
328            if (searchDocumentModel == null) {
329                throw new NuxeoException(
330                        String.format("Cannot build query of provider '%s': " + "no search document model is set",
331                                provider.getName()));
332            }
333            String additionalClause = NXQLQueryBuilder.appendClause(aggregatesClause, quickFiltersClause);
334            query = NXQLQueryBuilder.getQuery(searchDocumentModel, whereClause, additionalClause, parameters, null);
335        }
336        return query;
337    }
338
339    @SuppressWarnings({ "rawtypes", "unchecked" })
340    protected static String buildAggregatesClause(PageProvider provider) {
341        try {
342            // Aggregates that are being used as filters are stored in the namedParameters context data
343            Properties namedParameters = (Properties) provider.getSearchDocumentModel()
344                                                              .getContextData(NAMED_PARAMETERS);
345            if (namedParameters == null) {
346                return "";
347            }
348            Map<String, Aggregate<? extends Bucket>> aggregates = provider.getAggregates();
349            String aggregatesClause = "";
350            for (Aggregate<? extends Bucket> aggregate : aggregates.values()) {
351                if (namedParameters.containsKey(aggregate.getId())) {
352                    JsonNode node = OBJECT_MAPPER.readTree(namedParameters.get(aggregate.getId()));
353                    // Remove leading trailing and trailing quotes caused by
354                    // the JSON serialization of the named parameters
355                    List<String> keys = StreamSupport.stream(node.spliterator(), false)
356                                                     .map(value -> value.asText().replaceAll("^\"|\"$", ""))
357                                                     .collect(Collectors.toList());
358                    // Build aggregate clause from given keys in the named parameters
359                    String aggClause = aggregate.getBuckets()
360                                                .stream()
361                                                .filter(bucket -> keys.contains(bucket.getKey()))
362                                                .map(bucket -> getClauseFromBucket(bucket, aggregate.getXPathField()))
363                                                .collect(Collectors.joining(" OR "));
364                    if (StringUtils.isNotEmpty(aggClause)) {
365                        aggClause = "(" + aggClause + ")";
366                        aggregatesClause = StringUtils.isEmpty(aggregatesClause) ? aggClause
367                                : NXQLQueryBuilder.appendClause(aggregatesClause, aggClause);
368                    }
369                }
370            }
371            return aggregatesClause;
372        } catch (IOException e) {
373            throw new NuxeoException(e);
374        }
375    }
376
377    protected static String getClauseFromBucket(Bucket bucket, String field) {
378        String clause;
379        // Replace potential '.' path separator with '/' character
380        field = field.replaceAll("\\.", "/");
381        if (bucket instanceof BucketTerm) {
382            clause = field + "='" + bucket.getKey() + "'";
383        } else if (bucket instanceof BucketRange) {
384            BucketRange bucketRange = (BucketRange) bucket;
385            clause = getRangeClause(field, bucketRange);
386        } else if (bucket instanceof BucketRangeDate) {
387            BucketRangeDate bucketRangeDate = (BucketRangeDate) bucket;
388            clause = getRangeDateClause(field, bucketRangeDate);
389        } else {
390            throw new NuxeoException("Unknown bucket instance for NXQL translation : " + bucket.getClass());
391        }
392        return clause;
393    }
394
395    protected static String getRangeClause(String field, BucketRange bucketRange) {
396        Type type = Framework.getService(SchemaManager.class).getField(field).getType();
397        Double from = bucketRange.getFrom() != null ? bucketRange.getFrom() : Double.NEGATIVE_INFINITY;
398        Double to = bucketRange.getTo() != null ? bucketRange.getTo() : Double.POSITIVE_INFINITY;
399        if (type instanceof IntegerType) {
400            return field + " BETWEEN " + from.intValue() + " AND " + to.intValue();
401        } else if (type instanceof LongType) {
402            return field + " BETWEEN " + from.longValue() + " AND " + to.longValue();
403        }
404        return field + " BETWEEN " + from + " AND " + to;
405    }
406
407    protected static String getRangeDateClause(String field, BucketRangeDate bucketRangeDate) {
408        Double from = bucketRangeDate.getFrom();
409        Double to = bucketRangeDate.getTo();
410        if (from == null && to != null) {
411            return field + " < TIMESTAMP '" + formatISODateTime(nowIfNull(bucketRangeDate.getToAsDate())) + "'";
412        } else if (from != null && to == null) {
413            return field + " >= TIMESTAMP '" + formatISODateTime(nowIfNull(bucketRangeDate.getFromAsDate())) + "'";
414        }
415        return field + " BETWEEN TIMESTAMP '" + formatISODateTime(nowIfNull(bucketRangeDate.getFromAsDate()))
416                + "' AND TIMESTAMP '" + formatISODateTime(nowIfNull(bucketRangeDate.getToAsDate())) + "'";
417    }
418
419    protected static String appendToPattern(String pattern, String clause) {
420        return StringUtils.containsIgnoreCase(pattern, " WHERE ") ? NXQLQueryBuilder.appendClause(pattern, clause)
421                : pattern + " WHERE " + clause;
422    }
423
424    /**
425     * Resolves additional parameters that could have been defined in the contribution.
426     *
427     * @param parameters parameters from the operation
428     */
429    public static Object[] resolveELParameters(PageProviderDefinition def, Object... parameters) {
430        ELService elService = Framework.getService(ELService.class);
431        if (elService == null) {
432            return parameters;
433        }
434
435        // resolve additional parameters
436        String[] params = def.getQueryParameters();
437        if (params == null) {
438            params = new String[0];
439        }
440
441        Object[] resolvedParams = new Object[params.length + (parameters != null ? parameters.length : 0)];
442
443        ELContext elContext = elService.createELContext();
444
445        int i = 0;
446        if (parameters != null) {
447            i = parameters.length;
448            System.arraycopy(parameters, 0, resolvedParams, 0, i);
449        }
450        for (int j = 0; j < params.length; j++) {
451            ValueExpression ve = ELActionContext.EXPRESSION_FACTORY.createValueExpression(elContext, params[j],
452                    Object.class);
453            resolvedParams[i + j] = ve.getValue(elContext);
454        }
455        return resolvedParams;
456    }
457
458    private PageProviderHelper() {
459        // utility class
460    }
461}