001/*
002 * File   : $Source$
003 * Date   : $Date$
004 * Version: $Revision$
005 *
006 * This library is part of OpenCms -
007 * the Open Source Content Management System
008 *
009 * Copyright (C) 2002 - 2008 Alkacon Software (https://www.alkacon.com)
010 *
011 * This library is free software; you can redistribute it and/or
012 * modify it under the terms of the GNU Lesser General Public
013 * License as published by the Free Software Foundation; either
014 * version 2.1 of the License, or (at your option) any later version.
015 *
016 * This library is distributed in the hope that it will be useful,
017 * but WITHOUT ANY WARRANTY; without even the implied warranty of
018 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
019 * Lesser General Public License for more details.
020 *
021 * For further information about Alkacon Software, please see the
022 * company website: https://www.alkacon.com
023 *
024 * For further information about OpenCms, please see the
025 * project website: https://www.opencms.org
026 *
027 * You should have received a copy of the GNU Lesser General Public
028 * License along with this library; if not, write to the Free Software
029 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
030 */
031
032package org.opencms.search.solr;
033
034import org.opencms.file.CmsObject;
035import org.opencms.file.CmsPropertyDefinition;
036import org.opencms.main.OpenCms;
037import org.opencms.search.fields.CmsSearchField;
038import org.opencms.util.CmsFileUtil;
039import org.opencms.util.CmsPair;
040import org.opencms.util.CmsRequestUtil;
041import org.opencms.util.CmsStringUtil;
042
043import java.util.ArrayList;
044import java.util.Arrays;
045import java.util.Collections;
046import java.util.Date;
047import java.util.HashMap;
048import java.util.List;
049import java.util.Locale;
050import java.util.Map;
051import java.util.stream.Collectors;
052
053import org.apache.solr.client.solrj.request.SolrQuery;
054import org.apache.solr.common.params.CommonParams;
055
056/**
057 * A Solr search query.<p>
058 */
059public class CmsSolrQuery extends SolrQuery {
060
061    /** A constant to add the score field to the result documents. */
062    public static final String ALL_RETURN_FIELDS = "*,score";
063
064    /** The default facet date gap. */
065    public static final String DEFAULT_FACET_DATE_GAP = "+1DAY";
066
067    /** The default query. */
068    public static final String DEFAULT_QUERY = "*:*";
069
070    /** The query type. */
071    public static final String DEFAULT_QUERY_TYPE = "edismax";
072
073    /** The default search result count. */
074    public static final Integer DEFAULT_ROWS = Integer.valueOf(10);
075
076    /** A constant to add the score field to the result documents. */
077    public static final String MINIMUM_FIELDS = CmsSearchField.FIELD_PATH
078        + ","
079        + CmsSearchField.FIELD_TYPE
080        + ","
081        + CmsSearchField.FIELD_SOLR_ID
082        + ","
083        + CmsSearchField.FIELD_ID;
084
085    /** A constant to add the score field to the result documents. */
086    public static final String STRUCTURE_FIELDS = CmsSearchField.FIELD_PATH
087        + ","
088        + CmsSearchField.FIELD_TYPE
089        + ","
090        + CmsSearchField.FIELD_ID
091        + ","
092        + CmsSearchField.FIELD_CATEGORY
093        + ","
094        + CmsSearchField.FIELD_DATE_CONTENT
095        + ","
096        + CmsSearchField.FIELD_DATE_CREATED
097        + ","
098        + CmsSearchField.FIELD_DATE_EXPIRED
099        + ","
100        + CmsSearchField.FIELD_DATE_LASTMODIFIED
101        + ","
102        + CmsSearchField.FIELD_DATE_RELEASED
103        + ","
104        + CmsSearchField.FIELD_SUFFIX
105        + ","
106        + CmsSearchField.FIELD_DEPENDENCY_TYPE
107        + ","
108        + CmsSearchField.FIELD_DESCRIPTION
109        + ","
110        + CmsPropertyDefinition.PROPERTY_TITLE
111        + CmsSearchField.FIELD_DYNAMIC_PROPERTIES
112        + ","
113        + CmsSearchField.FIELD_RESOURCE_LOCALES
114        + ","
115        + CmsSearchField.FIELD_CONTENT_LOCALES
116        + ","
117        + CmsSearchField.FIELD_SCORE
118        + ","
119        + CmsSearchField.FIELD_PARENT_FOLDERS;
120
121    /** The serial version UID. */
122    private static final long serialVersionUID = -2387357736597627703L;
123
124    /** The facet date gap to use for date facets. */
125    private String m_facetDateGap = DEFAULT_FACET_DATE_GAP;
126
127    /** Ignore expiration flag. */
128    private boolean m_ignoreExpiration;
129
130    /** The parameters given by the 'query string'.  */
131    private Map<String, String[]> m_queryParameters = new HashMap<String, String[]>();
132
133    /** The search words. */
134    private String m_text;
135
136    /** The name of the field to search the text in. */
137    private List<String> m_textSearchFields = new ArrayList<String>();
138
139    /**
140     * Default constructor.<p>
141     */
142    public CmsSolrQuery() {
143
144        this(null, null);
145    }
146
147    /**
148     * Public constructor.<p>
149     *
150     * @param cms the current OpenCms context
151     * @param queryParams the Solr query parameters
152     */
153    public CmsSolrQuery(CmsObject cms, Map<String, String[]> queryParams) {
154
155        setQuery(DEFAULT_QUERY);
156        setFields(ALL_RETURN_FIELDS);
157        setRequestHandler(DEFAULT_QUERY_TYPE);
158        setRows(DEFAULT_ROWS);
159
160        // set the values from the request context
161        if (cms != null) {
162            setLocales(Collections.singletonList(cms.getRequestContext().getLocale()));
163            setSearchRoots(Collections.singletonList(cms.getRequestContext().getSiteRoot() + "/"));
164        }
165        if (queryParams != null) {
166            m_queryParameters = queryParams;
167        }
168        ensureParameters();
169        ensureReturnFields();
170        ensureExpiration();
171    }
172
173    /**
174     * Returns the resource type if only one is set as filter query.<p>
175     *
176     * @param fqs the field queries to check
177     *
178     * @return the type or <code>null</code>
179     */
180    public static String getResourceType(String[] fqs) {
181
182        String ret = null;
183        int count = 0;
184        if (fqs != null) {
185            for (String fq : fqs) {
186                if (fq.startsWith(CmsSearchField.FIELD_TYPE + ":")) {
187                    String val = fq.substring((CmsSearchField.FIELD_TYPE + ":").length());
188                    val = val.replaceAll("\"", "");
189                    if (OpenCms.getResourceManager().hasResourceType(val)) {
190                        count++;
191                        ret = val;
192                    }
193                }
194            }
195        }
196        return (count == 1) ? ret : null;
197    }
198
199    /**
200     * Creates and adds a filter query.<p>
201     *
202     * @param fieldName the field name to create a filter query on
203     * @param vals the values that should match for the given field
204     * @param all <code>true</code> to combine the given values with 'AND', <code>false</code> for 'OR'
205     * @param useQuotes <code>true</code> to surround the given values with double quotes, <code>false</code> otherwise
206     */
207    public void addFilterQuery(String fieldName, List<String> vals, boolean all, boolean useQuotes) {
208
209        if (getFilterQueries() != null) {
210            for (String fq : getFilterQueries()) {
211                if (fq.startsWith(fieldName + ":")) {
212                    removeFilterQuery(fq);
213                }
214            }
215        }
216        addFilterQuery(createFilterQuery(fieldName, vals, all, useQuotes));
217    }
218
219    /**
220     * Adds the given fields/orders to the existing sort fields.<p>
221     *
222     * @param sortFields the sortFields to set
223     */
224    public void addSortFieldOrders(Map<String, ORDER> sortFields) {
225
226        if ((sortFields != null) && !sortFields.isEmpty()) {
227            // add the sort fields to the query
228            for (Map.Entry<String, ORDER> entry : sortFields.entrySet()) {
229                addSort(entry.getKey(), entry.getValue());
230            }
231        }
232    }
233
234    /**
235     * @see java.lang.Object#clone()
236     */
237    @Override
238    public CmsSolrQuery clone() {
239
240        CmsSolrQuery sq = new CmsSolrQuery(null, CmsRequestUtil.createParameterMap(toString(), true, null));
241        if (m_ignoreExpiration) {
242            sq.removeExpiration();
243        }
244        return sq;
245    }
246
247    /**
248     * Ensures that the initial request parameters will overwrite the member values.<p>
249     *
250     * You can initialize the query with an HTTP request parameter then make some method calls
251     * and finally re-ensure that the initial request parameters will overwrite the changes
252     * made in the meanwhile.<p>
253     */
254    public void ensureParameters() {
255
256        // overwrite already set values with values from query String
257        if ((m_queryParameters != null) && !m_queryParameters.isEmpty()) {
258            for (Map.Entry<String, String[]> entry : m_queryParameters.entrySet()) {
259                if (!entry.getKey().equals(CommonParams.FQ)) {
260                    // add or replace all parameters from the query String
261                    setParam(entry.getKey(), entry.getValue());
262                } else {
263                    // special handling for filter queries
264                    replaceFilterQueries(entry.getValue());
265                }
266            }
267        }
268    }
269
270    /**
271     * Removes the expiration flag.
272     */
273    public void removeExpiration() {
274
275        if (getFilterQueries() != null) {
276            for (String fq : getFilterQueries()) {
277                if (fq.startsWith(CmsSearchField.FIELD_DATE_EXPIRED + ":")
278                    || fq.startsWith(CmsSearchField.FIELD_DATE_RELEASED + ":")) {
279                    removeFilterQuery(fq);
280                }
281            }
282        }
283        m_ignoreExpiration = true;
284    }
285
286    /**
287     * Sets the categories only if not set in the query parameters.<p>
288     *
289     * @param categories the categories to set
290     */
291    public void setCategories(List<String> categories) {
292
293        if ((categories != null) && !categories.isEmpty()) {
294            addFilterQuery(CmsSearchField.FIELD_CATEGORY + CmsSearchField.FIELD_DYNAMIC_EXACT, categories, true, true);
295        }
296    }
297
298    /**
299     * Sets the categories only if not set in the query parameters.<p>
300     *
301     * @param categories the categories to set
302     */
303    public void setCategories(String... categories) {
304
305        setCategories(Arrays.asList(categories));
306    }
307
308    /**
309     * Sets date ranges.<p>
310     *
311     * This call will overwrite all existing date ranges for the given keys (name of the date facet field).<p>
312     *
313     * The parameter Map uses as:<p>
314     * <ul>
315     * <li><code>keys: </code>Solr field name {@link org.opencms.search.fields.CmsSearchField} and
316     * <li><code>values: </code> pairs with min date as first and max date as second {@link org.opencms.util.CmsPair}
317     * </ul>
318     * Alternatively you can use Solr standard query syntax like:<p>
319     * <ul>
320     * <li><code>+created:[* TO NOW]</code>
321     * <li><code>+lastmodified:[' + date + ' TO NOW]</code>
322     * </ul>
323     * whereby date is Solr formatted:
324     * {@link org.opencms.search.CmsSearchUtil#getDateAsIso8601(Date)}
325     * <p>
326     *
327     * @param dateRanges the ranges map with field name as key and a CmsPair with min date as first and max date as second
328     */
329    public void setDateRanges(Map<String, CmsPair<Date, Date>> dateRanges) {
330
331        if ((dateRanges != null) && !dateRanges.isEmpty()) {
332            // remove the date ranges
333            for (Map.Entry<String, CmsPair<Date, Date>> entry : dateRanges.entrySet()) {
334                removeFacetField(entry.getKey());
335            }
336            // add the date ranges
337            for (Map.Entry<String, CmsPair<Date, Date>> entry : dateRanges.entrySet()) {
338                addDateRangeFacet(
339                    entry.getKey(),
340                    entry.getValue().getFirst(),
341                    entry.getValue().getSecond(),
342                    m_facetDateGap);
343            }
344        }
345    }
346
347    /**
348     * Sets the facetDateGap.<p>
349     *
350     * @param facetDateGap the facetDateGap to set
351     */
352    public void setFacetDateGap(String facetDateGap) {
353
354        m_facetDateGap = facetDateGap;
355    }
356
357    /**
358     * Sets the Geo filter query if not exists.
359     * @param fieldName the field name storing the coordinates
360     * @param coordinates the coordinates string as a lat,lng pair
361     * @param radius the radius
362     * @param units the units of the search radius
363     */
364    public void setGeoFilterQuery(String fieldName, String coordinates, String radius, String units) {
365
366        String geoFilterQuery = CmsSolrQueryUtil.composeGeoFilterQuery(fieldName, coordinates, radius, units);
367        if (!Arrays.asList(getFilterQueries()).contains(geoFilterQuery)) {
368            addFilterQuery(geoFilterQuery);
369        }
370    }
371
372    /**
373     * Sets the highlightFields.<p>
374     *
375     * @param highlightFields the highlightFields to set
376     */
377    public void setHighlightFields(List<String> highlightFields) {
378
379        setParam("hl.fl", CmsStringUtil.listAsString(highlightFields, ","));
380    }
381
382    /**
383     * Sets the highlightFields.<p>
384     *
385     * @param highlightFields the highlightFields to set
386     */
387    public void setHighlightFields(String... highlightFields) {
388
389        setParam("hl.fl", CmsStringUtil.arrayAsString(highlightFields, ","));
390    }
391
392    /**
393     * Sets the locales only if not set in the query parameters.<p>
394     *
395     * @param locales the locales to set
396     */
397    public void setLocales(List<Locale> locales) {
398
399        m_textSearchFields = new ArrayList<String>();
400        if ((locales == null) || locales.isEmpty()) {
401            m_textSearchFields.add(CmsSearchField.FIELD_TEXT);
402            if (getFilterQueries() != null) {
403                for (String fq : getFilterQueries()) {
404                    if (fq.startsWith(CmsSearchField.FIELD_CONTENT_LOCALES + ":")) {
405                        removeFilterQuery(fq);
406                    }
407                }
408            }
409        } else {
410            List<String> localeStrings = new ArrayList<String>();
411            for (Locale locale : locales) {
412                localeStrings.add(locale.toString());
413                if (!m_textSearchFields.contains("text")
414                    && !OpenCms.getLocaleManager().getAvailableLocales().contains(locale)) {
415                    // if the locale is not configured in the opencms-system.xml
416                    // there will no localized text fields, so take the general one
417                    m_textSearchFields.add("text");
418                } else {
419                    m_textSearchFields.add("text_" + locale);
420                }
421            }
422            addFilterQuery(CmsSearchField.FIELD_CONTENT_LOCALES, localeStrings, false, false);
423        }
424        if (m_text != null) {
425            setText(m_text);
426        }
427    }
428
429    /**
430     * Sets the locales only if not set in the query parameters.<p>
431     *
432     * @param locales the locales to set
433     */
434    public void setLocales(Locale... locales) {
435
436        setLocales(Arrays.asList(locales));
437    }
438
439    /**
440     * Sets the request handler.<p>
441     *
442     * @param qt the request handler
443     *
444     * @return this query
445     */
446    @Override
447    public SolrQuery setRequestHandler(String qt) {
448
449        SolrQuery q = super.setRequestHandler(qt);
450        if (m_text != null) {
451            setText(m_text);
452        }
453        return q;
454    }
455
456    /**
457     * Sets the resource types only if not set in the query parameters.<p>
458     *
459     * @param resourceTypes the resourceTypes to set
460     */
461    public void setResourceTypes(List<String> resourceTypes) {
462
463        if ((resourceTypes != null) && !resourceTypes.isEmpty()) {
464            addFilterQuery(CmsSearchField.FIELD_TYPE, resourceTypes, false, false);
465        }
466    }
467
468    /**
469     * Sets the resource types only if not set in the query parameters.<p>
470     *
471     * @param resourceTypes the resourceTypes to set
472     */
473    public void setResourceTypes(String... resourceTypes) {
474
475        setResourceTypes(Arrays.asList(resourceTypes));
476    }
477
478    /**
479     * Sets the requested return fields, but ensures that at least the 'path' and the 'type', 'id' and 'solr_id'
480     * are part of the fields returned field list.<p>
481     *
482     * @param returnFields the really requested return fields.
483     *
484     * @see CommonParams#FL
485     */
486    public void setReturnFields(String returnFields) {
487
488        ensureReturnFields(new String[] {returnFields});
489    }
490
491    /**
492     * Sets the search roots only if not set as query parameter.<p>
493     *
494     * @param searchRoots the searchRoots to set
495     */
496    public void setSearchRoots(List<String> searchRoots) {
497
498        if (searchRoots != null) {
499            searchRoots = searchRoots.stream().filter(r -> r != null).map(CmsFileUtil::addTrailingSeparator).collect(
500                Collectors.toList());
501            if (!searchRoots.isEmpty()) {
502                addFilterQuery(CmsSearchField.FIELD_PARENT_FOLDERS, searchRoots, false, true);
503            }
504        }
505    }
506
507    /**
508     * Sets the search roots only if not set as query parameter.<p>
509     *
510     * @param searchRoots the searchRoots to set
511     */
512    public void setSearchRoots(String... searchRoots) {
513
514        setSearchRoots(Arrays.asList(searchRoots));
515    }
516
517    /**
518     * Sets the return fields 'fl' to a predefined set that does not contain content specific fields.<p>
519     *
520     * @param structureQuery the <code>true</code> to return only structural fields
521     */
522    public void setStructureQuery(boolean structureQuery) {
523
524        if (structureQuery) {
525            setFields(STRUCTURE_FIELDS);
526        }
527    }
528
529    /**
530     * Sets the text.<p>
531     *
532     * @param text the text to set
533     */
534    public void setText(String text) {
535
536        m_text = text;
537        if (CmsStringUtil.isNotEmptyOrWhitespaceOnly(text)) {
538            setQuery(createTextQuery(text));
539        }
540    }
541
542    /**
543     * Sets the textSearchFields.<p>
544     *
545     * @param textSearchFields the textSearchFields to set
546     */
547    public void setTextSearchFields(List<String> textSearchFields) {
548
549        m_textSearchFields = textSearchFields;
550        if (m_text != null) {
551            setText(m_text);
552        }
553    }
554
555    /**
556     * Sets the textSearchFields.<p>
557     *
558     * @param textSearchFields the textSearchFields to set
559     */
560    public void setTextSearchFields(String... textSearchFields) {
561
562        setTextSearchFields(Arrays.asList(textSearchFields));
563    }
564
565    /**
566     * Creates a filter query on the given field name.<p>
567     *
568     * Creates and adds a filter query.<p>
569     *
570     * @param fieldName the field name to create a filter query on
571     * @param vals the values that should match for the given field
572     * @param all <code>true</code> to combine the given values with 'AND', <code>false</code> for 'OR'
573     * @param useQuotes <code>true</code> to surround the given values with double quotes, <code>false</code> otherwise
574     *
575     * @return a filter query String e.g. <code>fq=fieldname:val1</code>
576     */
577    private String createFilterQuery(String fieldName, List<String> vals, boolean all, boolean useQuotes) {
578
579        String filterQuery = null;
580        if ((vals != null)) {
581            if (vals.size() == 1) {
582                if (useQuotes) {
583                    filterQuery = fieldName + ":" + "\"" + vals.get(0) + "\"";
584                } else {
585                    filterQuery = fieldName + ":" + vals.get(0);
586                }
587            } else if (vals.size() > 1) {
588                filterQuery = fieldName + ":(";
589                for (int j = 0; j < vals.size(); j++) {
590                    String val;
591                    if (useQuotes) {
592                        val = "\"" + vals.get(j) + "\"";
593                    } else {
594                        val = vals.get(j);
595                    }
596                    filterQuery += val;
597                    if (vals.size() > (j + 1)) {
598                        if (all) {
599                            filterQuery += " AND ";
600                        } else {
601                            filterQuery += " OR ";
602                        }
603                    }
604                }
605                filterQuery += ")";
606            }
607        }
608        return filterQuery;
609    }
610
611    /**
612     * Creates a OR combined 'q' parameter.<p>
613     *
614     * @param text the query string.
615     *
616     * @return returns the 'q' parameter
617     */
618    private String createTextQuery(String text) {
619
620        if (m_textSearchFields.isEmpty()) {
621            m_textSearchFields.add(CmsSearchField.FIELD_TEXT);
622        }
623        String q = "{!q.op=OR type=" + getRequestHandler() + " qf=\"";
624        q += String.join(" ", m_textSearchFields);
625        q += "\"}" + text;
626        return q;
627    }
628
629    /**
630     * Ensures that expired and not yet released resources are not returned by default.<p>
631     */
632    private void ensureExpiration() {
633
634        boolean expirationDateSet = false;
635        boolean releaseDateSet = false;
636        if (getFilterQueries() != null) {
637            for (String fq : getFilterQueries()) {
638                if (fq.startsWith(CmsSearchField.FIELD_DATE_EXPIRED + ":")) {
639                    expirationDateSet = true;
640                }
641                if (fq.startsWith(CmsSearchField.FIELD_DATE_RELEASED + ":")) {
642                    releaseDateSet = true;
643                }
644            }
645        }
646        if (!expirationDateSet) {
647            addFilterQuery(CmsSearchField.FIELD_DATE_EXPIRED + ":[NOW TO *]");
648        }
649        if (!releaseDateSet) {
650            addFilterQuery(CmsSearchField.FIELD_DATE_RELEASED + ":[* TO NOW]");
651        }
652    }
653
654    /**
655     * Ensures that at least the 'path' and the 'type', 'id' and 'solr_id' are part of the fields returned field list.<p>
656     *
657     * @see CommonParams#FL
658     */
659    private void ensureReturnFields() {
660
661        ensureReturnFields(getParams(CommonParams.FL));
662    }
663
664    /**
665     * Ensures that at least the 'path' and the 'type', 'id' and 'solr_id' are part of the fields returned field list.<p>
666     *
667     * @param requestedReturnFields the really requested return fields.
668     *
669     * @see CommonParams#FL
670     */
671    private void ensureReturnFields(String[] requestedReturnFields) {
672
673        if ((requestedReturnFields != null) && (requestedReturnFields.length > 0)) {
674            List<String> result = new ArrayList<String>();
675            for (String field : requestedReturnFields) {
676                List<String> list = CmsStringUtil.splitAsList(field, ',');
677                list.forEach(e -> e.trim());
678                if (!list.contains("*")) {
679                    for (String reqField : CmsStringUtil.splitAsList(MINIMUM_FIELDS, ",")) {
680                        if (!list.contains(reqField)) {
681                            list.add(reqField);
682                        }
683                    }
684                }
685                result.addAll(list);
686            }
687            setParam(CommonParams.FL, CmsStringUtil.arrayAsString(result.toArray(new String[0]), ","));
688        }
689    }
690
691    /**
692     * Removes those filter queries that restrict the fields used in the given filter query Strings.<p>
693     *
694     * Searches in the given Strings for a ":", then takes the field name part
695     * and removes the already set filter queries queries that are matching the same field name.<p>
696     *
697     * @param fqs the filter query Strings in the format <code>fq=fieldname:value</code> that should be removed
698     */
699    private void removeFilterQueries(String[] fqs) {
700
701        // iterate over the given filter queries to remove
702        for (String fq : fqs) {
703            int idx = fq.indexOf(':');
704            if (idx != -1) {
705                // get the field name of the fq to remove
706                String fieldName = fq.substring(0, idx);
707                // iterate over the fqs of the already existing fqs from the solr query
708                if (getFilterQueries() != null) {
709                    for (String sfq : getFilterQueries()) {
710                        if (sfq.startsWith(fieldName + ":")) {
711                            // there exists a filter query for exact the same field,  remove it
712                            removeFilterQuery(sfq);
713                        }
714                    }
715                }
716            }
717        }
718    }
719
720    /**
721     * Removes the given filter queries, if already set and then adds the filter queries again.<p>
722     *
723     * @param fqs the filter queries to remove
724     */
725    private void replaceFilterQueries(String[] fqs) {
726
727        removeFilterQueries(fqs);
728        addFilterQuery(fqs);
729    }
730}