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