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