/*
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.xwiki.search.solr.internal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.solr.common.params.MapSolrParams;
import org.apache.solr.common.params.SolrParams;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.search.ExtendedDismaxQParserPlugin;
import org.apache.solr.search.QParser;
import org.apache.solr.util.SolrPluginUtils;
/**
* Extends {@link ExtendedDismaxQParserPlugin} in order to add dynamic aliases for multilingual fields which are
* expanded in the search query. This way, a user can write a query on the {@code title} field and all the
* {@code title_<language>} variations of the field will be used in the query. The list of languages for which a field
* is expanded is taken from the {@code xwiki.supportedLocales} query parameter. If this parameter is not defined, the
* ROOT locale is used instead. The list of multilingual fields is determined based on the
* {@code xwiki.multilingualFields}.
* <p>
* The current approach is to extract the field names from the search query and to add the alias parameters before the
* query is parsed. We tried the following solutions too, but they failed:
* <ul>
* <li>We tried to extended {@code ExtendedDismaxQParser} and override {@code getFieldName()} in order to detect the
* fields that appear in the search query and add the alias parameters for them but unfortunately
* {@code ExtendedDismaxQParser} calls {@code ExtendedSolrQueryParser#addAliasesFromRequest()} before splitting the
* search query in clauses.</li>
* <li>We tried to expand the {@code Query} object returned by {@code ExtendedSolrQueryParser#parse()} but it doesn't
* support iteration and it has lots of subclasses so we had to check the type of query and perform special iteration
* and special changes for each of these subclasses.</li>
* </ul>
*
* @version $Id: 5ad15c0d4c5609e8cd06194ea4c3e5127e776828 $
* @since 5.3RC1
*/
public class XWikiDismaxQParserPlugin extends ExtendedDismaxQParserPlugin
{
/**
* The pattern used to split list configuration parameters.
*/
private static final Pattern LIST_SEPARATOR = Pattern.compile("\\s*,\\s*");
/**
* The pattern used to extract field names from a search query. The field name starts with a lower case letter (the
* names of dynamic fields should start with a prefix) or with underscore and can contain Unicode letters and
* digits, plus also the following special characters: '_' (underscore), '-' (dash), '.' (dot) and '$' (dollar).
* Also, the field name appears either at the start of the query or after one of these: '+' (plus), '-' (minus), '('
* (round left bracket) or a white space.
*
* @see ExtendedDismaxQParser#getFieldName()
*/
private static final Pattern FIELD_PATTERN = Pattern.compile("(?:^|[+\\-(\\s])([a-z_][\\p{L}\\p{N}_\\-.$]*):");
/**
* The string used to define a dynamic field.
*/
private static final String WILDCARD = "*";
@Override
public QParser createParser(String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req)
{
return super.createParser(qstr, localParams, withFieldAliases(qstr, params), req);
}
/**
* Extends the given search parameters with aliases for the fields that appear in the given search query.
*
* @param query the search query where to look for field names
* @param parameters the search parameters to extend
* @return the extended search parameters
*/
public SolrParams withFieldAliases(String query, SolrParams parameters)
{
Set<String> fieldNames = extractFieldNames(query);
// Add default query fields (these fields are used to search for free text that appears in the query).
String defaultQueryFields = parameters.get("qf");
if (defaultQueryFields != null) {
fieldNames.addAll(SolrPluginUtils.parseFieldBoosts(defaultQueryFields).keySet());
}
if (fieldNames.isEmpty()) {
return parameters;
}
Map<String, String> aliasParameters = new HashMap<String, String>();
addMultilingualFieldAliases(fieldNames, aliasParameters, parameters);
addTypedDynamicFieldAliases(fieldNames, aliasParameters, parameters);
return aliasParameters.isEmpty() ? parameters : SolrParams.wrapDefaults(new MapSolrParams(aliasParameters),
parameters);
}
/**
* Adds aliases for multilingual fields.
*
* @param fieldNames the set of field names to add aliases for
* @param aliasParameters the map where the aliases are collected
* @param parameters the search query parameters used to extract the list of multilingual fields and the list of
* supported locales
*/
private void addMultilingualFieldAliases(Set<String> fieldNames, Map<String, String> aliasParameters,
SolrParams parameters)
{
List<String> multilingualFields = getListParameter("xwiki.multilingualFields", parameters);
if (multilingualFields.isEmpty()) {
return;
}
// There is at least one supported locale, the ROOT locale.
List<String> supportedLocales = getSupportedLocales(parameters);
for (String fieldName : fieldNames) {
if (matchesFieldName(fieldName, multilingualFields)) {
addAliases(fieldName, supportedLocales, aliasParameters);
}
}
}
/**
* Adds aliases for typed dynamic fields.
* <p>
* The names of the non-string dynamic fields must be suffixed with the data type (instead of the locale) in order
* for them to be indexed correctly. Thus we need to add aliases for dynamic field names that will match the
* configured data types.
*
* @param fieldNames the set of field names to add aliases for
* @param aliasParameters the map where the aliases are collected
* @param parameters the search query parameters used to extract the list of typed dynamic fields and the list of
* supported data types
*/
private void addTypedDynamicFieldAliases(Set<String> fieldNames, Map<String, String> aliasParameters,
SolrParams parameters)
{
List<String> typedDynamicFields = getListParameter("xwiki.typedDynamicFields", parameters);
List<String> dynamicFieldTypes = getListParameter("xwiki.dynamicFieldTypes", parameters);
if (typedDynamicFields.isEmpty() || dynamicFieldTypes.isEmpty()) {
return;
}
for (String fieldName : fieldNames) {
if (matchesFieldName(fieldName, typedDynamicFields)) {
addAliases(fieldName, dynamicFieldTypes, aliasParameters);
}
}
}
/**
* Extracts the field names from the given search query.
*
* @param query the search query
* @return the set of field names
*/
public Set<String> extractFieldNames(String query)
{
Set<String> fieldNames = new HashSet<String>();
Matcher matcher = FIELD_PATTERN.matcher(query);
while (matcher.find()) {
fieldNames.add(matcher.group(1));
}
return fieldNames;
}
/**
* Get the value of a list parameter.
*
* @param parameter the name of a list parameter (its value is a comma-separated list of strings)
* @param parameters the query parameters
* @return the list value
*/
private static List<String> getListParameter(String parameter, SolrParams parameters)
{
String value = parameters.get(parameter);
if (value != null) {
return Arrays.asList(LIST_SEPARATOR.split(value));
} else {
return Collections.emptyList();
}
}
/**
* @param parameters the query parameters
* @return the list of supported locales
*/
private static List<String> getSupportedLocales(SolrParams parameters)
{
List<String> supportedLocalesList = new ArrayList<String>();
supportedLocalesList.add("_");
String supportedLocales = parameters.get("xwiki.supportedLocales");
if (supportedLocales != null) {
supportedLocalesList.addAll(Arrays.asList(LIST_SEPARATOR.split(supportedLocales)));
}
return supportedLocalesList;
}
/**
* @param fieldName the field name to match
* @param fieldNamePatterns the list of field name patterns; a field name pattern is a string that can start or
* end with a {@link #WILDCARD}.
* @return {@code true} if at least one of the field name patterns matches the given field name, {@code false}
* otherwise
*/
private boolean matchesFieldName(String fieldName, List<String> fieldNamePatterns)
{
for (String fieldNamePattern : fieldNamePatterns) {
if (fieldNamePattern.equals(fieldName)) {
return true;
} else if (fieldNamePattern.endsWith(WILDCARD)) {
if (fieldName.startsWith(fieldNamePattern.substring(0, fieldNamePattern.length() - 1))) {
return true;
}
} else if (fieldNamePattern.startsWith(WILDCARD)) {
if (fieldName.endsWith(fieldNamePattern.substring(1))) {
return true;
}
}
}
return false;
}
/**
* Adds aliases for the specified field to the given parameters.
*
* @param fieldName a field name
* @param suffixes the list of alias suffixes
* @param aliasParameters where to add the aliases
*/
private void addAliases(String fieldName, List<String> suffixes, Map<String, String> aliasParameters)
{
String aliasParameterName = String.format("f.%s.qf", fieldName);
StringBuilder aliasParameterValue = new StringBuilder();
for (String suffix : suffixes) {
aliasParameterValue.append(' ').append(fieldName).append('_').append(suffix);
}
String previousValue = aliasParameters.get(aliasParameterName);
aliasParameters.put(aliasParameterName, previousValue == null ? aliasParameterValue.substring(1)
: previousValue + aliasParameterValue.toString());
}
}