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