001/* 002 * Copyright 2016 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.cms.search.solr; 017 018import java.net.URLDecoder; 019import java.util.ArrayList; 020import java.util.Collection; 021import java.util.HashMap; 022import java.util.LinkedHashMap; 023import java.util.List; 024import java.util.Map; 025 026import org.apache.avalon.framework.activity.Initializable; 027import org.apache.avalon.framework.component.Component; 028import org.apache.avalon.framework.service.ServiceException; 029import org.apache.avalon.framework.service.ServiceManager; 030import org.apache.avalon.framework.service.Serviceable; 031import org.apache.commons.collections.CollectionUtils; 032import org.apache.commons.lang3.StringUtils; 033import org.apache.solr.client.solrj.SolrClient; 034import org.apache.solr.client.solrj.SolrQuery; 035import org.apache.solr.client.solrj.SolrQuery.ORDER; 036import org.apache.solr.client.solrj.SolrRequest.METHOD; 037import org.apache.solr.client.solrj.response.FacetField; 038import org.apache.solr.client.solrj.response.FacetField.Count; 039import org.apache.solr.client.solrj.response.QueryResponse; 040import org.slf4j.Logger; 041 042import org.ametys.cms.search.JoinableSearchField; 043import org.ametys.cms.search.SearchField; 044import org.ametys.cms.search.SearchResults; 045import org.ametys.cms.search.Sort; 046import org.ametys.cms.search.Sort.Order; 047import org.ametys.cms.search.query.Query; 048import org.ametys.cms.search.query.QuerySyntaxException; 049import org.ametys.core.user.CurrentUserProvider; 050import org.ametys.core.user.UserIdentity; 051import org.ametys.plugins.repository.AmetysObject; 052import org.ametys.plugins.repository.AmetysObjectIterable; 053import org.ametys.plugins.repository.AmetysObjectResolver; 054import org.ametys.runtime.plugin.component.AbstractLogEnabled; 055 056/** 057 * Component searching objects corresponding to a {@link Query}. 058 */ 059public class SearcherFactory extends AbstractLogEnabled implements Component, Serviceable, Initializable 060{ 061 062 /** The component role. */ 063 public static final String ROLE = SearcherFactory.class.getName(); 064 065 /** The {@link AmetysObjectResolver} */ 066 protected AmetysObjectResolver _resolver; 067 068 /** The solr client provider */ 069 protected SolrClientProvider _solrClientProvider; 070 071 /** The current user provider. */ 072 protected CurrentUserProvider _currentUserProvider; 073 074 /** The solr client */ 075 protected SolrClient _solrClient; 076 077 @Override 078 public void service(ServiceManager serviceManager) throws ServiceException 079 { 080 _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE); 081 _solrClientProvider = (SolrClientProvider) serviceManager.lookup(SolrClientProvider.ROLE); 082 _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE); 083 } 084 085 @Override 086 public void initialize() throws Exception 087 { 088 _solrClient = _solrClientProvider.getReadClient(); 089 } 090 091 /** 092 * Create a Searcher. 093 * @return a Searcher object. 094 */ 095 public Searcher create() 096 { 097 return new Searcher(getLogger()); 098 } 099 100 /** 101 * Class searching objects corresponding to a query, with optional sort, facets, and so on. 102 */ 103 public class Searcher 104 { 105 private Logger _logger; 106 107 private String _queryString; 108 private Query _query; 109 private List<String> _filterQueryStrings; 110 private List<Query> _filterQueries; 111 private List<Sort> _sortClauses; 112 private List<SearchField> _facets; 113 private Map<String, List<String>> _facetValues; 114 private int _start; 115 private int _maxResults; 116 private Map<String, Object> _searchContext; 117 private boolean _checkRights; 118 119 /** 120 * Build a Searcher with default values. 121 * @param logger The logger. 122 */ 123 protected Searcher(Logger logger) 124 { 125 _logger = logger; 126 127 _filterQueryStrings = new ArrayList<>(); 128 _filterQueries = new ArrayList<>(); 129 _sortClauses = new ArrayList<>(); 130 _facets = new ArrayList<>(); 131 _facetValues = new HashMap<>(); 132 _start = 0; 133 _maxResults = Integer.MAX_VALUE; 134 _searchContext = new HashMap<>(); 135 _checkRights = true; 136 } 137 138 /** 139 * Set the query (as a String). 140 * @param query the query (as a String). 141 * @return The Searcher object itself. 142 */ 143 public Searcher withQueryString(String query) 144 { 145 if (this._query != null) 146 { 147 throw new IllegalArgumentException("Query and query string can't be used at the same time."); 148 } 149 this._queryString = query; 150 return this; 151 } 152 153 /** 154 * Set the query (as a {@link Query} object). 155 * @param query the query (as a {@link Query} object). 156 * @return The Searcher object itself. 157 */ 158 public Searcher withQuery(Query query) 159 { 160 if (this._queryString != null) 161 { 162 throw new IllegalArgumentException("Query and query string can't be used at the same time."); 163 } 164 this._query = query; 165 return this; 166 } 167 168 /** 169 * Set the filter queries (as Strings). 170 * @param queries the filter queries (as Strings). 171 * @return The Searcher object itself. The Searcher object itself. 172 */ 173 public Searcher withFilterQueryStrings(String... queries) 174 { 175 _filterQueryStrings = new ArrayList<>(queries.length); 176 CollectionUtils.addAll(_filterQueryStrings, queries); 177 return this; 178 } 179 180 /** 181 * Set the filter queries (as Strings). 182 * @param queries the filter queries (as Strings). 183 * @return The Searcher object itself. The Searcher object itself. 184 */ 185 public Searcher withFilterQueryStrings(Collection<String> queries) 186 { 187 _filterQueryStrings = new ArrayList<>(queries); 188 return this; 189 } 190 191 /** 192 * Add a filter query to the existing ones (as a String). 193 * @param query the filter query to add (as a String). 194 * @return The Searcher object itself. The Searcher object itself. 195 */ 196 public Searcher addFilterQueryString(String query) 197 { 198 _filterQueryStrings.add(query); 199 return this; 200 } 201 202 /** 203 * Set the filter queries (as {@link Query} objects). 204 * @param queries the filter queries (as {@link Query} objects). 205 * @return The Searcher object itself. The Searcher object itself. 206 */ 207 public Searcher withFilterQueries(Query... queries) 208 { 209 _filterQueries = new ArrayList<>(queries.length); 210 CollectionUtils.addAll(_filterQueries, queries); 211 return this; 212 } 213 214 /** 215 * Set the filter queries (as {@link Query} objects). 216 * @param queries the filter queries (as {@link Query} objects). 217 * @return The Searcher object itself. The Searcher object itself. 218 */ 219 public Searcher withFilterQueries(Collection<Query> queries) 220 { 221 _filterQueries = new ArrayList<>(queries); 222 return this; 223 } 224 225 /** 226 * Add a filter query to the existing ones (as a {@link Query} object). 227 * @param query the filter query to add (as a {@link Query} object). 228 * @return The Searcher object itself. The Searcher object itself. 229 */ 230 public Searcher addFilterQuery(Query query) 231 { 232 _filterQueries.add(query); 233 return this; 234 } 235 236 /** 237 * Set the sort clauses. 238 * @param sortClauses the sort clauses. 239 * @return The Searcher object itself. 240 */ 241 public Searcher withSort(Sort... sortClauses) 242 { 243 _sortClauses = new ArrayList<>(sortClauses.length); 244 CollectionUtils.addAll(_sortClauses, sortClauses); 245 return this; 246 } 247 248 /** 249 * Set the sort clauses. 250 * @param sortClauses the sort clauses. 251 * @return The Searcher object itself. 252 */ 253 public Searcher withSort(List<Sort> sortClauses) 254 { 255 _sortClauses = new ArrayList<>(sortClauses); 256 return this; 257 } 258 259 /** 260 * Add a sort clause to the existing ones. 261 * @param sortClause The sort clause to add. 262 * @return The Searcher object itself. 263 */ 264 public Searcher addSort(Sort sortClause) 265 { 266 _sortClauses.add(sortClause); 267 return this; 268 } 269 270 /** 271 * Set the faceted fields. 272 * @param facets the faceted fields. 273 * @return The Searcher object itself. 274 */ 275 public Searcher withFacets(SearchField... facets) 276 { 277 _facets = new ArrayList<>(facets.length); 278 CollectionUtils.addAll(_facets, facets); 279 return this; 280 } 281 282 /** 283 * Set the faceted fields. 284 * @param facets the faceted fields. 285 * @return The Searcher object itself. 286 */ 287 public Searcher withFacets(Collection<SearchField> facets) 288 { 289 _facets = new ArrayList<>(facets); 290 return this; 291 } 292 293 /** 294 * Add a faceted field. 295 * @param facet The faceted field to add. 296 * @return The Searcher object itself. 297 */ 298 public Searcher addFacet(SearchField facet) 299 { 300 _facets.add(facet); 301 return this; 302 } 303 304 /** 305 * Set the facet values. 306 * @param facetValues The facet values. 307 * @return The Searcher object itself. 308 */ 309 public Searcher withFacetValues(Map<String, List<String>> facetValues) 310 { 311 _facetValues = new HashMap<>(facetValues); 312 return this; 313 } 314 315 /** 316 * Set the search offset and limit. 317 * @param start The start index (offset). 318 * @param maxResults The maximum number of results. 319 * @return The Searcher object itself. 320 */ 321 public Searcher withLimits(int start, int maxResults) 322 { 323 this._start = start; 324 this._maxResults = maxResults; 325 return this; 326 } 327 328 /** 329 * Set the search context. 330 * @param searchContext The search context. 331 * @return The Searcher object itself. 332 */ 333 public Searcher withContext(Map<String, Object> searchContext) 334 { 335 _searchContext = new HashMap<>(searchContext); 336 return this; 337 } 338 339 /** 340 * Add a value to the search context. 341 * @param key The context key. 342 * @param value The value. 343 * @return The Searcher object itself. 344 */ 345 public Searcher addContextElement(String key, Object value) 346 { 347 _searchContext.put(key, value); 348 return this; 349 } 350 351 /** 352 * Whether to check rights when searching, false otherwise. 353 * @param checkRights <code>true</code> to check rights, <code>false</code> otherwise. 354 * @return The Searcher object itself. 355 */ 356 public Searcher setCheckRights(boolean checkRights) 357 { 358 _checkRights = checkRights; 359 return this; 360 } 361 362 /** 363 * Execute the search with the current parameters. 364 * @param <A> The type of search results 365 * @return An iterable on the result ametys objects. 366 * @throws Exception If an error occurs. 367 */ 368 public <A extends AmetysObject> AmetysObjectIterable<A> search() throws Exception 369 { 370 SearchResults<A> searchResults = searchWithFacets(); 371 return searchResults.getObjects(); 372 } 373 374 /** 375 * Execute the search with the current parameters. 376 * @param <A> The type of search results 377 * @return An iterable on the search result objects. 378 * @throws Exception If an error occurs. 379 */ 380 public <A extends AmetysObject> SearchResults<A> searchWithFacets() throws Exception 381 { 382 QueryResponse response = _querySolrServer(); 383 return _buildResults(response, _facets); 384 } 385 386 /** 387 * From the Solr server response, builds the {@link SearchResults} object. 388 * @param <A> The type of search results 389 * @param response The response from the Solr server 390 * @param facets The facet fields to return 391 * @return An iterable on the search result objects. 392 * @throws Exception If an error occurs. 393 */ 394 protected <A extends AmetysObject> SearchResults<A> _buildResults(QueryResponse response, List<SearchField> facets) throws Exception 395 { 396 Map<String, Map<String, Integer>> facetResults = getFacetResults(response, facets); 397 return new SolrSearchResults<>(response, _resolver, facetResults); 398 } 399 400 private QueryResponse _querySolrServer() throws Exception 401 { 402 String query = getQuery(); 403 List<String> filterQueries = getFilterQueries(); 404 405 SolrQuery solrQuery = getSolrQuery(query, filterQueries, _start, _maxResults, _searchContext, _checkRights); 406 407 // Set the sort specification and facets in the solr query object. 408 setSort(solrQuery, _sortClauses); 409 setFacets(solrQuery, _facets, _facetValues); 410 411 modifySolrQuery(solrQuery); 412 413 if (_logger.isInfoEnabled()) 414 { 415 _logger.info("Solr query: " + URLDecoder.decode(solrQuery.toString(), "UTF-8")); 416 } 417 418 // Use POST to avoid problems with large requests. 419 return _solrClient.query(_solrClientProvider.getCollectionName(), solrQuery, METHOD.POST); 420 } 421 422 /** 423 * Get the query string from the parameters. 424 * @return The query string. 425 * @throws QuerySyntaxException If the query is invalid. 426 */ 427 protected String getQuery() throws QuerySyntaxException 428 { 429 String query = "*:*"; 430 if (_queryString != null) 431 { 432 query = _queryString; 433 } 434 else if (_query != null) 435 { 436 query = _query.build(); 437 } 438 return query; 439 } 440 441 /** 442 * Get the filter queries from the parameters. 443 * @return The list of filter queries. 444 * @throws QuerySyntaxException If one of the queries is invalid. 445 */ 446 protected List<String> getFilterQueries() throws QuerySyntaxException 447 { 448 List<String> filterQueries = new ArrayList<>(); 449 if (!_filterQueryStrings.isEmpty()) 450 { 451 filterQueries = _filterQueryStrings; 452 } 453 454 if (!_filterQueries.isEmpty()) 455 { 456 for (Query fq : _filterQueries) 457 { 458 filterQueries.add(fq.build()); 459 } 460 } 461 462 return filterQueries; 463 } 464 465 /** 466 * Get the solr query object. 467 * @param query The solr query string. 468 * @param filterQueries The filter queries (as Strings). 469 * @param start The start index. 470 * @param maxResults The maximum number of results. 471 * @param searchContext The search context. 472 * @param checkRights Whether to check rights when searching or not. 473 * @return The solr query object. 474 * @throws Exception If an error occurs. 475 */ 476 protected SolrQuery getSolrQuery(String query, Collection<String> filterQueries, int start, int maxResults, Map<String, Object> searchContext, boolean checkRights) throws Exception 477 { 478 SolrQuery solrQuery = new SolrQuery(); 479 480 String queryString = StringUtils.isNotBlank(query) ? query : "*:*"; 481 482 // Set the query string, pagination spec and fields to be returned. 483 solrQuery.setQuery(queryString); 484 solrQuery.setStart(start); 485 solrQuery.setRows(maxResults); 486 // Return only ID + score fields. 487 solrQuery.setFields("id", "score"); 488 489 // Add filter queries. 490 for (String fq : filterQueries) 491 { 492 solrQuery.addFilterQuery(fq); 493 } 494 495 if (checkRights) 496 { 497 StringBuilder aclFilterQuery = new StringBuilder("{!acl "); 498 UserIdentity user = _currentUserProvider.getUser(); 499 if (user == null) 500 { 501 aclFilterQuery.append("anonymous="); 502 } 503 else 504 { 505 aclFilterQuery.append("populationId="); 506 aclFilterQuery.append(user.getPopulationId()); 507 aclFilterQuery.append(" login="); 508 aclFilterQuery.append(user.getLogin()); 509 } 510 aclFilterQuery.append("}"); 511 // {!acl anonymous=} or {!acl populationId=users login=user1} to check acl 512 solrQuery.addFilterQuery(aclFilterQuery.toString()); 513 } 514 515 return solrQuery; 516 } 517 518 /** 519 * Set the sort definition in the solr query object. 520 * @param solrQuery The solr query object. 521 * @param sortCriteria The sort criteria. 522 */ 523 protected void setSort(SolrQuery solrQuery, List<Sort> sortCriteria) 524 { 525 if (sortCriteria.isEmpty()) 526 { 527 solrQuery.addSort("score", ORDER.desc); 528 } 529 for (Sort sortCriterion : sortCriteria) 530 { 531 solrQuery.addSort(sortCriterion.getField(), sortCriterion.getOrder() == Order.ASC ? ORDER.asc : ORDER.desc); 532 } 533 } 534 535 /** 536 * Set the facet definition in the solr query object and return a mapping from solr field name to criterion ID. 537 * @param solrQuery the solr query object to fill. 538 * @param facets The facet definitions to use. 539 * @param facetValues the facet values. 540 */ 541 protected void setFacets(SolrQuery solrQuery, Collection<SearchField> facets, Map<String, List<String>> facetValues) 542 { 543 if (facets.size() > 0) 544 { 545 // unlimited facet values 546 solrQuery.setFacetLimit(-1); 547 solrQuery.setFacet(true); 548 } 549 550 for (SearchField facetField : facets) 551 { 552 String fieldName = facetField.getName(); 553 String solrFieldName = facetField.getFacetField(); 554 555 if (StringUtils.isNotBlank(fieldName)) 556 { 557 if (facetField instanceof JoinableSearchField && ((JoinableSearchField) facetField).isJoined()) 558 { 559 _setJoinedFacet(solrQuery, fieldName, facetValues, (JoinableSearchField) facetField); 560 } 561 else 562 { 563 _setNonJoinedFacet(solrQuery, fieldName, solrFieldName, facetValues); 564 } 565 } 566 } 567 } 568 569 private void _setJoinedFacet(SolrQuery solrQuery, String fieldName, Map<String, List<String>> facetValues, JoinableSearchField metadataFacetField) 570 { 571 List<String> fieldFacetValues = facetValues.get(fieldName); 572 if (fieldFacetValues != null && !fieldFacetValues.isEmpty()) 573 { 574 StringBuilder fq = new StringBuilder(); 575 fq.append("{!tag=").append(fieldName).append("}("); 576 577 int i = 0; 578 fq.append("{!ametys join=").append(metadataFacetField.getJoinedPath()).append(" q=\""); 579 for (String facetValue : fieldFacetValues) 580 { 581 if (i > 0) 582 { 583 fq.append(" OR "); 584 } 585 fq.append(metadataFacetField.getFacetField()).append(":\\\"").append(facetValue).append("\\\""); 586 i++; 587 } 588 fq.append("\"})"); 589 solrQuery.addFilterQuery(fq.toString()); 590 } 591 592 solrQuery.add("facet.function", "{!ex=" + fieldName + " key=" + fieldName + "}" + metadataFacetField.getFacetFunction()); 593 } 594 595 private void _setNonJoinedFacet(SolrQuery solrQuery, String fieldName, String solrFieldName, Map<String, List<String>> facetValues) 596 { 597 List<String> fieldFacetValues = facetValues.get(fieldName); 598 if (fieldFacetValues != null && !fieldFacetValues.isEmpty()) 599 { 600 StringBuilder fq = new StringBuilder(); 601 fq.append("{!tag=").append(fieldName).append("}("); 602 603 int i = 0; 604 for (String facetValue : fieldFacetValues) 605 { 606 if (i > 0) 607 { 608 fq.append(" OR "); 609 } 610 // TODO escape query chars? 611 fq.append(solrFieldName).append(":\"").append(facetValue).append('"'); 612 i++; 613 } 614 fq.append(')'); 615 solrQuery.addFilterQuery(fq.toString()); 616 } 617 618 String facetFieldDef = "{!ex=" + fieldName + " key=" + fieldName + "}" + solrFieldName; 619 solrQuery.addFacetField(facetFieldDef); 620 } 621 622 /** 623 * Retrieve the facet results from the solr response. 624 * @param response the solr response. 625 * @param facets The facet fields to return. 626 * @return the facet results. 627 */ 628 protected Map<String, Map<String, Integer>> getFacetResults(QueryResponse response, Collection<SearchField> facets) 629 { 630 Map<String, Map<String, Integer>> facetResults = new LinkedHashMap<>(); 631 632 for (SearchField facetField : facets) 633 { 634 String fieldName = facetField.getName(); 635 FacetField solrFacetField = response.getFacetField(fieldName); 636 637 List<Count> values = solrFacetField.getValues(); 638 639 Map<String, Integer> solrFacetValues = new HashMap<>(); 640 facetResults.put(fieldName, solrFacetValues); 641 642 for (Count count : values) 643 { 644 solrFacetValues.put(count.getName(), (int) count.getCount()); 645 } 646 } 647 648 return facetResults; 649 } 650 651 /** 652 * Template method to do additional operations on the Solr query before passing it to the Solr client 653 * @param query the Solr query 654 */ 655 protected void modifySolrQuery(SolrQuery query) 656 { 657 // do nothing by default 658 } 659 } 660 661}