Issue/vivo 3606 : add language-specific sorting and label fields to search index (#321)

* Add select query document modifier with dynamic target field; use locale-specific sort fields when available.

* Add i18nized labels to index for autocomplete

* Remove lowercasing from label query

* Improved document modifier for multilingual field with defined suffix name

* Improved document modifier for multilingual field with defined suffix name

* refact: reverted access modifier changes

* Lowercase label in documentModifierI18nSort in case old solr schema is used which doesn't have lowercase filter

* fix: fixed queries and locale names

* fix: renamed new document modifier

* fix: use linkedHashMap to retain map sort fields order

* refact: extracted buildAndExecuteVClassQuery(List<String> classUris, int page, int pageSize, String alpha, VitroRequest vreq)

* fix: removed unused import

* fix: constant name aligned with other suffix

Co-authored-by: Brian Lowe <brian@ontocale.com>
This commit is contained in:
Georgy Litvinov 2022-08-23 16:23:43 +02:00 committed by GitHub
parent 6ad364f9ee
commit df3c4a88ae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 327 additions and 27 deletions

View file

@ -6,9 +6,11 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import edu.cornell.mannlib.vitro.webapp.web.templatemodels.individuallist.ListedIndividualBuilder;
import javax.servlet.annotation.WebServlet;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@ -16,6 +18,7 @@ import org.apache.commons.logging.LogFactory;
import edu.cornell.mannlib.vitro.webapp.beans.Individual;
import edu.cornell.mannlib.vitro.webapp.beans.VClass;
import edu.cornell.mannlib.vitro.webapp.beans.VClassGroup;
import edu.cornell.mannlib.vitro.webapp.config.ConfigurationProperties;
import edu.cornell.mannlib.vitro.webapp.controller.VitroRequest;
import edu.cornell.mannlib.vitro.webapp.controller.freemarker.responsevalues.ExceptionResponseValues;
import edu.cornell.mannlib.vitro.webapp.controller.freemarker.responsevalues.ResponseValues;
@ -27,8 +30,7 @@ import edu.cornell.mannlib.vitro.webapp.modules.searchEngine.SearchEngineExcepti
import edu.cornell.mannlib.vitro.webapp.modules.searchEngine.SearchQuery;
import edu.cornell.mannlib.vitro.webapp.utils.searchengine.SearchQueryUtils;
import edu.cornell.mannlib.vitro.webapp.web.templatemodels.individuallist.ListedIndividual;
import javax.servlet.annotation.WebServlet;
import edu.cornell.mannlib.vitro.webapp.web.templatemodels.individuallist.ListedIndividualBuilder;
/**
* Generates a list of individuals for display in a template
@ -43,6 +45,7 @@ public class IndividualListController extends FreemarkerHttpServlet {
private static final int MAX_PAGES = 40; // must be even
private static final String TEMPLATE_DEFAULT = "individualList.ftl";
private static final String LANGUAGE_FILTER_PROPERTY = "RDFService.languageFilter";
@Override
protected ResponseValues processRequest(VitroRequest vreq) {
@ -152,12 +155,12 @@ public class IndividualListController extends FreemarkerHttpServlet {
return SearchQueryUtils.getPageParameter(request);
}
public static IndividualListResults getResultsForVClass(String vclassURI, int page, String alpha, VitroRequest vreq)
public static IndividualListResults getResultsForVClass(String vclassURI,
int page, String alpha, VitroRequest vreq)
throws SearchException{
try{
List<String> classUris = Collections.singletonList(vclassURI);
IndividualListQueryResults results = buildAndExecuteVClassQuery(classUris, alpha, page, INDIVIDUALS_PER_PAGE, vreq.getWebappDaoFactory().getIndividualDao());
return getResultsForVClassQuery(results, page, INDIVIDUALS_PER_PAGE, alpha, vreq);
List<String> classUris = Collections.singletonList(vclassURI);
return buildAndExecuteVClassQuery(classUris, page, INDIVIDUALS_PER_PAGE, alpha, vreq);
} catch (SearchEngineException e) {
String msg = "An error occurred retrieving results for vclass query";
log.error(msg, e);
@ -169,16 +172,27 @@ public class IndividualListController extends FreemarkerHttpServlet {
}
}
public static IndividualListResults getResultsForVClassIntersections(List<String> vclassURIs, int page, int pageSize, String alpha, VitroRequest vreq) {
public static IndividualListResults getResultsForVClassIntersections(
List<String> classUris, int page, int pageSize, String alpha, VitroRequest vreq) {
try{
IndividualListQueryResults results = buildAndExecuteVClassQuery(vclassURIs, alpha, page, pageSize, vreq.getWebappDaoFactory().getIndividualDao());
return getResultsForVClassQuery(results, page, pageSize, alpha, vreq);
return buildAndExecuteVClassQuery(classUris, page, pageSize, alpha, vreq);
} catch(Throwable th) {
log.error("Error retrieving individuals corresponding to intersection multiple classes." + vclassURIs.toString(), th);
log.error("Error retrieving individuals corresponding to intersection multiple classes." + classUris.toString(), th);
return IndividualListResults.EMPTY;
}
}
private static IndividualListResults buildAndExecuteVClassQuery(List<String> classUris, int page, int pageSize,
String alpha, VitroRequest vreq) throws SearchEngineException {
ConfigurationProperties props = ConfigurationProperties.getBean(vreq);
boolean languageFilter = Boolean.valueOf(props.getProperty(LANGUAGE_FILTER_PROPERTY, "false"));
IndividualListQueryResults results = buildAndExecuteVClassQuery(classUris, alpha,
((languageFilter) ? vreq.getLocale() : null), page, pageSize,
vreq.getWebappDaoFactory().getIndividualDao());
IndividualListResults indListResults = getResultsForVClassQuery(results, page, pageSize, alpha, vreq);
return indListResults;
}
public static IndividualListResults getRandomResultsForVClass(String vclassURI, int page, int pageSize, VitroRequest vreq) {
try{
List<String> classUris = Collections.singletonList(vclassURI);
@ -201,9 +215,10 @@ public class IndividualListController extends FreemarkerHttpServlet {
private static IndividualListQueryResults buildAndExecuteVClassQuery(
List<String> vclassURIs, String alpha, int page, int pageSize, IndividualDao indDao)
List<String> vclassURIs, String alpha, Locale locale, int page,
int pageSize, IndividualDao indDao)
throws SearchEngineException {
SearchQuery query = SearchQueryUtils.getQuery(vclassURIs, alpha, page, pageSize);
SearchQuery query = SearchQueryUtils.getQuery(vclassURIs, alpha, locale, page, pageSize);
IndividualListQueryResults results = IndividualListQueryResults.runQuery(query, indDao);
log.debug("Executed search query for " + vclassURIs);
if (results.getIndividuals().isEmpty()) {

View file

@ -70,5 +70,11 @@ public class VitroSearchTermNames {
/** Source institution name */
public static final String SITE_NAME = "siteName";
/** Multilingual sort field suffix */
public static final String LABEL_SORT_SUFFIX = "_label_sort";
/** Multilingual label field suffix */
public static final String LABEL_DISPLAY_SUFFIX = "_label_display";
}

View file

@ -36,6 +36,7 @@ import edu.cornell.mannlib.vitro.webapp.modules.searchEngine.SearchResultDocumen
import edu.cornell.mannlib.vitro.webapp.rdfservice.RDFService;
import edu.cornell.mannlib.vitro.webapp.rdfservice.impl.RDFServiceUtils;
import edu.cornell.mannlib.vitro.webapp.search.VitroSearchTermNames;
import edu.cornell.mannlib.vitro.webapp.utils.searchengine.SearchQueryUtils;
/**
* AutocompleteController generates autocomplete content
@ -127,7 +128,10 @@ public class AutocompleteController extends VitroAjaxController {
for (SearchResultDocument doc : docs) {
try {
String uri = doc.getStringValue(VitroSearchTermNames.URI);
String name = doc.getStringValue(VitroSearchTermNames.NAME_RAW);
String name = doc.getStringValue(SearchQueryUtils.getLabelFieldNameForLocale(vreq.getLocale()));
if (name == null) {
name = doc.getStringValue(VitroSearchTermNames.NAME_RAW);
}
//There may be multiple most specific types, sending them all back
String mst = doc.getStringValue(VitroSearchTermNames.MOST_SPECIFIC_TYPE_URIS);
//Assuming these will get me string values
@ -184,7 +188,9 @@ public class AutocompleteController extends VitroAjaxController {
addFilterQuery(query, typeParam, multipleTypesParam);
}
query.addFields(VitroSearchTermNames.NAME_RAW, VitroSearchTermNames.URI, VitroSearchTermNames.MOST_SPECIFIC_TYPE_URIS); // fields to retrieve
query.addFields(SearchQueryUtils.getLabelFieldNameForLocale(vreq.getLocale()),
VitroSearchTermNames.NAME_RAW, VitroSearchTermNames.URI,
VitroSearchTermNames.MOST_SPECIFIC_TYPE_URIS); // fields to retrieve
// Can't sort on multivalued field, so we sort the results in Java when we get them.
// query.addSortField(VitroSearchTermNames.NAME_LOWERCASE, Order.ASC);

View file

@ -5,8 +5,8 @@ package edu.cornell.mannlib.vitro.webapp.searchengine.base;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
@ -21,7 +21,7 @@ public class BaseSearchQuery implements SearchQuery {
private int rows = -1;
private final Set<String> fieldsToReturn = new HashSet<>();
private final Map<String, SearchQuery.Order> sortFields = new HashMap<>();
private final Map<String, SearchQuery.Order> sortFields = new LinkedHashMap <>();
private final Set<String> filters = new HashSet<>();
private final Set<String> facetFields = new HashSet<>();

View file

@ -0,0 +1,72 @@
package edu.cornell.mannlib.vitro.webapp.searchengine.solr;
import static edu.cornell.mannlib.vitro.webapp.search.VitroSearchTermNames.LABEL_DISPLAY_SUFFIX;
import static edu.cornell.mannlib.vitro.webapp.search.VitroSearchTermNames.LABEL_SORT_SUFFIX;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.apache.solr.client.solrj.SolrClient;
import org.apache.solr.client.solrj.SolrQuery;
import org.apache.solr.client.solrj.impl.ConcurrentUpdateSolrClient;
import org.apache.solr.client.solrj.request.schema.SchemaRequest;
import org.apache.solr.client.solrj.response.QueryResponse;
import org.apache.solr.client.solrj.response.schema.SchemaResponse;
import org.apache.solr.common.params.CommonParams;
import org.apache.solr.common.util.SimpleOrderedMap;
public class SolrFieldInitializer {
static void initializeFields(SolrClient queryEngine, ConcurrentUpdateSolrClient updateEngine) throws Exception {
Set<String> fieldSuffixes = new HashSet<>(Arrays.asList(LABEL_SORT_SUFFIX, LABEL_DISPLAY_SUFFIX));
excludeMatchedFields(fieldSuffixes, queryEngine, "dynamicFields");
excludeMatchedFields(fieldSuffixes, queryEngine, "fields");
createMissingFields(fieldSuffixes, updateEngine);
}
private static void createMissingFields(Set<String> fieldSuffixes, ConcurrentUpdateSolrClient updateEngine)
throws Exception {
for (String suffix : fieldSuffixes) {
Map<String, Object> fieldAttributes = getFieldAttributes(suffix);
SchemaRequest.AddDynamicField request = new SchemaRequest.AddDynamicField(fieldAttributes);
SchemaResponse.UpdateResponse response = request.process(updateEngine);
if (response.getStatus() != 0) {
throw new Exception("Creation of missing solr field '*" + suffix + "' failed");
}
}
}
private static Map<String, Object> getFieldAttributes(String suffix) {
Map<String, Object> fieldAttributes = new HashMap<String, Object>();
fieldAttributes.put("type", "string");
fieldAttributes.put("stored", "true");
fieldAttributes.put("indexed", "true");
fieldAttributes.put("name", "*" + suffix);
return fieldAttributes;
}
private static void excludeMatchedFields(Set<String> fieldSuffixes, SolrClient queryEngine, String fieldType)
throws Exception {
SolrQuery query = new SolrQuery();
query.add(CommonParams.QT, "/schema/" + fieldType.toLowerCase());
QueryResponse response = queryEngine.query(query);
ArrayList<SimpleOrderedMap> fieldList = (ArrayList<SimpleOrderedMap>) response.getResponse().get(fieldType);
if (fieldList == null) {
return;
}
Set<String> it = new HashSet<>(fieldSuffixes);
for (String target : it) {
for (SimpleOrderedMap field : fieldList) {
String fieldName = (String) field.get("name");
if (fieldName.endsWith(target)) {
fieldSuffixes.remove(target);
}
}
}
}
}

View file

@ -76,7 +76,9 @@ public class SolrSearchEngine implements SearchEngine {
// no apparent 7.4.0 analogy to `setPollQueueTime(25)`
updateEngine = updateBuilder.build();
SolrFieldInitializer.initializeFields(queryEngine, updateEngine);
css.info("Set up the Solr search engine; URL = '" + solrServerUrlString + "'.");
} catch (Exception e) {
css.fatal("Could not set up the Solr search engine", e);

View file

@ -52,13 +52,13 @@ public class SelectQueryDocumentModifier implements DocumentModifier,
private static final Log log = LogFactory
.getLog(SelectQueryDocumentModifier.class);
private RDFService rdfService;
protected RDFService rdfService;
/** A name to be used in logging, to identify this instance. */
private String label;
protected String label;
/** The queries to be executed. There must be at least one. */
private List<String> queries = new ArrayList<>();
protected List<String> queries = new ArrayList<>();
/**
* The names of the fields where the results of the queries will be stored.
@ -128,7 +128,7 @@ public class SelectQueryDocumentModifier implements DocumentModifier,
}
}
private boolean passesTypeRestrictions(Individual ind) {
protected boolean passesTypeRestrictions(Individual ind) {
if (typeRestrictions.isEmpty()) {
return true;
} else {

View file

@ -0,0 +1,111 @@
/* $This file is distributed under the terms of the license in LICENSE$ */
package edu.cornell.mannlib.vitro.webapp.searchindex.documentBuilding;
import static edu.cornell.mannlib.vitro.webapp.utils.sparqlrunner.SparqlQueryRunner.createSelectQueryContext;
import static edu.cornell.mannlib.vitro.webapp.i18n.selection.LocaleSelectionSetup.PROPERTY_SELECTABLE_LOCALES;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import edu.cornell.mannlib.vitro.webapp.beans.Individual;
import edu.cornell.mannlib.vitro.webapp.config.ConfigurationProperties;
import edu.cornell.mannlib.vitro.webapp.modules.searchEngine.SearchInputDocument;
import edu.cornell.mannlib.vitro.webapp.rdfservice.filter.LanguageFilteringRDFService;
import edu.cornell.mannlib.vitro.webapp.utils.configuration.ConfigurationReader;
import edu.cornell.mannlib.vitro.webapp.utils.configuration.ContextModelsUser;
import edu.cornell.mannlib.vitro.webapp.utils.configuration.Property;
import edu.cornell.mannlib.vitro.webapp.utils.sparqlrunner.QueryHolder;
/**
* A variation on SelectQueryDocumentModifier where the suffix of target field is defined.
* Multiple queries are performed for each of locales configured in runtime.properties
*
* Target field names are composed of locale + fieldSuffix.
*
* Each query should contain a ?uri variable, which will be replaced by the URI
* of the individual.
*
* All of the other result fields in each row of each query will be converted to
* strings and added to the field.
*
*/
public class SelectQueryI18nDocumentModifier extends SelectQueryDocumentModifier
implements DocumentModifier, ContextModelsUser, ConfigurationReader {
private static final Log log = LogFactory.getLog(SelectQueryI18nDocumentModifier.class);
private String fieldSuffix = "";
private ArrayList<String> locales = new ArrayList<>();
@Property(uri = "http://vitro.mannlib.cornell.edu/ns/vitro/ApplicationSetup#hasTargetSuffix")
public void setTargetSuffix(String fieldSuffix) {
this.fieldSuffix = fieldSuffix;
}
@Override
public void modifyDocument(Individual ind, SearchInputDocument doc) {
if (passesTypeRestrictions(ind) && StringUtils.isNotBlank(fieldSuffix)) {
List<Map<String, List<String>>> maps = getTextForQueries(ind);
for (Map<String, List<String>> map : maps) {
for (String locale : map.keySet()) {
List<String> values = map.get(locale);
String fieldName = locale + fieldSuffix;
doc.addField(fieldName, values);
}
}
}
}
protected List<Map<String, List<String>>> getTextForQueries(Individual ind) {
List<Map<String, List<String>>> list = new ArrayList<>();
for (String query : queries) {
list.add(getTextForQuery(query, ind));
}
return list;
}
protected Map<String, List<String>> getTextForQuery(String query, Individual ind) {
try {
QueryHolder queryHolder = new QueryHolder(query).bindToUri("uri", ind.getURI());
Map<String, List<String>> mapLocaleToFields = new HashMap<>();
for (String locale : locales) {
LanguageFilteringRDFService lfrs = new LanguageFilteringRDFService(rdfService,
Collections.singletonList(locale));
List<String> list = createSelectQueryContext(lfrs, queryHolder).execute().toStringFields().flatten();
mapLocaleToFields.put(locale, list);
log.debug(label + " for locale " + locale + " - query: '" + query + "' returns " + list);
}
return mapLocaleToFields;
} catch (Throwable t) {
log.error("problem while running query '" + query + "'", t);
return Collections.emptyMap();
}
}
@Override
public void setConfigurationProperties(ConfigurationProperties config) {
String property = config.getProperty(PROPERTY_SELECTABLE_LOCALES);
if (!StringUtils.isBlank(property)) {
String[] values = property.trim().split("\\s*,\\s*");
for (String value : values) {
String locale = value.replace("_", "-");
addLocale(locale);
}
}
}
private void addLocale(String localeString) {
if (StringUtils.isBlank(localeString)) {
return;
}
locales.add(localeString);
}
}

View file

@ -0,0 +1,13 @@
/* $This file is distributed under the terms of the license in LICENSE$ */
package edu.cornell.mannlib.vitro.webapp.utils.configuration;
import edu.cornell.mannlib.vitro.webapp.config.ConfigurationProperties;
/**
* When the ConfigurationBeanLoader creates an instance of this class, it will
* call this method, supplying ConfigurationProperties.
*/
public interface ConfigurationReader {
void setConfigurationProperties(ConfigurationProperties properties);
}

View file

@ -12,6 +12,7 @@ import java.util.Set;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import edu.cornell.mannlib.vitro.webapp.config.ConfigurationProperties;
import edu.cornell.mannlib.vitro.webapp.modelaccess.ModelAccess;
import edu.cornell.mannlib.vitro.webapp.utils.configuration.PropertyType.PropertyMethod;
import edu.cornell.mannlib.vitro.webapp.utils.configuration.PropertyType.PropertyStatement;
@ -62,6 +63,15 @@ public class WrappedInstance<T> {
rmu.setRequestModels(ModelAccess.on(req));
}
}
if (instance instanceof ConfigurationReader) {
if (ctx == null) {
throw new ResourceUnavailableException("Cannot satisfy "
+ "ConfigurationReader interface: context not available.");
} else {
ConfigurationReader cr = (ConfigurationReader) instance;
cr.setConfigurationProperties(ConfigurationProperties.getBean(ctx));
}
}
}
/**

View file

@ -5,6 +5,7 @@ package edu.cornell.mannlib.vitro.webapp.utils.searchengine;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
@ -150,19 +151,38 @@ public class SearchQueryUtils {
}
/**
* builds a query with a type clause for each type in vclassUris, NAME_LOWERCASE filetred by
* alpha, and just the hits for the page for pageSize.
* builds a query with a type clause for each type in vclassUris,
* NAME_LOWERCASE filtered by alpha, and just the hits for the page for pageSize.
* @param locale may be null. If null, default sort field will be used.
* Otherwise, query will be sorted by locale-specific sort field.
*/
public static SearchQuery getQuery(List<String> vclassUris, String alpha, int page, int pageSize){
public static SearchQuery getQuery(List<String> vclassUris, String alpha,
Locale locale, int page, int pageSize){
String queryText = "";
SearchEngine searchEngine = ApplicationUtils.instance().getSearchEngine();
try {
queryText = makeMultiClassQuery(vclassUris);
String localeSpecificField = null;
if (locale != null) {
localeSpecificField = getSortFieldNameForLocale(locale);
}
// Add alpha filter if applicable
if ( alpha != null && !"".equals(alpha) && alpha.length() == 1) {
queryText += VitroSearchTermNames.NAME_LOWERCASE + ":" + alpha.toLowerCase() + "*";
if (locale == null) {
queryText += VitroSearchTermNames.NAME_LOWERCASE + ":" + alpha.toLowerCase() + "*";
} else {
// Retrieve items matching the appropriate alpha char
// on the i18ned field if that field exists. For records
// where the field does not exist, fall back to NAME_LOWERCASE
queryText += "(" + localeSpecificField + ":" + alpha.toLowerCase()
+ "* OR (-" + localeSpecificField + ":[* TO *] AND "
+ VitroSearchTermNames.NAME_LOWERCASE + ":" + alpha.toLowerCase() + "*))";
log.debug("Multiclass query text: " + queryText);
}
}
SearchQuery query = searchEngine.createQuery(queryText);
@ -172,6 +192,11 @@ public class SearchQueryUtils {
query.setStart( startRow ).setRows( pageSize );
// Need a single-valued field for sorting
// Sort first by sort field for locale; fall back to
// NAME_LOWERCASE_SINGLE_VALUED if not available.
if(locale != null) {
query.addSortField(localeSpecificField, Order.ASC);
}
query.addSortField(VitroSearchTermNames.NAME_LOWERCASE_SINGLE_VALUED, Order.ASC);
log.debug("Query is " + query.toString());
@ -182,6 +207,14 @@ public class SearchQueryUtils {
return searchEngine.createQuery();
}
}
public static String getSortFieldNameForLocale(Locale locale) {
return locale.toString().replace('_', '-') + VitroSearchTermNames.LABEL_SORT_SUFFIX;
}
public static String getLabelFieldNameForLocale(Locale locale) {
return locale.toString().replace('_', '-') + VitroSearchTermNames.LABEL_DISPLAY_SUFFIX;
}
public static SearchQuery getRandomQuery(List<String> vclassUris, int page, int pageSize){
String queryText = "";

View file

@ -0,0 +1,16 @@
@prefix : <http://vitro.mannlib.cornell.edu/ns/vitro/ApplicationSetup#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
:documentModifier_multilingual_label
a <java:edu.cornell.mannlib.vitro.webapp.searchindex.documentBuilding.SelectQueryI18nDocumentModifier> ,
<java:edu.cornell.mannlib.vitro.webapp.searchindex.documentBuilding.DocumentModifier> ;
rdfs:label "multilingual label document modifier" ;
:hasTargetSuffix "_label_display" ;
:hasSelectQuery """
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT (MIN(?label) AS ?singleLabel ) WHERE {
?uri rdfs:label ?label .
BIND (LANG(?label) as ?lang )
} GROUP BY ?lang ORDER BY ?lang
""" .

View file

@ -0,0 +1,16 @@
@prefix : <http://vitro.mannlib.cornell.edu/ns/vitro/ApplicationSetup#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
:documentModifier_multilingual_sort
a <java:edu.cornell.mannlib.vitro.webapp.searchindex.documentBuilding.SelectQueryI18nDocumentModifier> ,
<java:edu.cornell.mannlib.vitro.webapp.searchindex.documentBuilding.DocumentModifier> ;
rdfs:label "multilingual sort document modifier" ;
:hasTargetSuffix "_label_sort" ;
:hasSelectQuery """
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ( LCASE(MIN(?label)) AS ?singleLabel ) WHERE {
?uri rdfs:label ?label .
BIND (LANG(?label) as ?lang )
} GROUP BY ?lang ORDER BY ?lang
""" .