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.content; 017 018import java.util.ArrayList; 019import java.util.Collection; 020import java.util.HashMap; 021import java.util.List; 022import java.util.Locale; 023import java.util.Map; 024import java.util.Objects; 025import java.util.Optional; 026import java.util.Set; 027import java.util.stream.Collectors; 028 029import org.apache.avalon.framework.component.Component; 030import org.apache.avalon.framework.context.Context; 031import org.apache.avalon.framework.context.ContextException; 032import org.apache.avalon.framework.context.Contextualizable; 033import org.apache.avalon.framework.service.ServiceException; 034import org.apache.avalon.framework.service.ServiceManager; 035import org.apache.avalon.framework.service.Serviceable; 036import org.apache.cocoon.components.ContextHelper; 037import org.apache.commons.lang3.StringUtils; 038 039import org.ametys.cms.contenttype.ContentType; 040import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 041import org.ametys.cms.contenttype.ContentTypesHelper; 042import org.ametys.cms.data.type.ModelItemTypeConstants; 043import org.ametys.cms.data.type.indexing.IndexableElementType; 044import org.ametys.cms.data.type.indexing.SortableIndexableElementType; 045import org.ametys.cms.model.ContentElementDefinition; 046import org.ametys.cms.model.properties.Property; 047import org.ametys.cms.repository.Content; 048import org.ametys.cms.search.SortOrder; 049import org.ametys.cms.search.cocoon.SearchAction; 050import org.ametys.cms.search.content.ContentSearcherFactory.ContentSearchSort; 051import org.ametys.cms.search.model.CriterionDefinition; 052import org.ametys.cms.search.model.CriterionDefinitionAwareElementDefinition; 053import org.ametys.cms.search.model.IndexationAwareElementDefinition; 054import org.ametys.cms.search.model.SearchModel; 055import org.ametys.cms.search.model.SystemPropertyExtensionPoint; 056import org.ametys.cms.search.model.impl.ReferencingCriterionDefinition; 057import org.ametys.cms.search.solr.SearcherFactory.FacetDefinition; 058import org.ametys.cms.search.solr.SearcherFactory.Searcher; 059import org.ametys.cms.search.solr.SearcherFactory.SortDefinition; 060import org.ametys.plugins.repository.model.RepeaterDefinition; 061import org.ametys.runtime.model.ElementDefinition; 062import org.ametys.runtime.model.ModelHelper; 063import org.ametys.runtime.model.ModelItem; 064import org.ametys.runtime.model.ModelItemAccessor; 065import org.ametys.runtime.model.ModelItemGroup; 066import org.ametys.runtime.model.ModelViewItem; 067import org.ametys.runtime.model.View; 068import org.ametys.runtime.model.ViewElement; 069import org.ametys.runtime.model.ViewItem; 070import org.ametys.runtime.model.ViewItemAccessor; 071import org.ametys.runtime.model.exception.BadItemTypeException; 072import org.ametys.runtime.model.exception.UndefinedItemPathException; 073import org.ametys.runtime.model.type.DataContext; 074import org.ametys.runtime.model.type.ModelItemType; 075import org.ametys.runtime.plugin.component.AbstractLogEnabled; 076 077/** 078 * Component which helps content searching by providing a simple way to access 079 * content properties (either attributes or properties). 080 */ 081public class ContentSearchHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable 082{ 083 /** The component role. */ 084 public static final String ROLE = ContentSearchHelper.class.getName(); 085 086 /** The content type extension point. */ 087 private ContentTypeExtensionPoint _contentTypeExtensionPoint; 088 089 /** The system property extension point. */ 090 private SystemPropertyExtensionPoint _systemPropertyExtensionPoint; 091 092 /** Content Types helper */ 093 private ContentTypesHelper _contentTypesHelper; 094 095 /** The avalon context */ 096 private Context _context; 097 098 @Override 099 public void service(ServiceManager manager) throws ServiceException 100 { 101 _contentTypeExtensionPoint = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 102 _systemPropertyExtensionPoint = (SystemPropertyExtensionPoint) manager.lookup(SystemPropertyExtensionPoint.ROLE); 103 _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 104 } 105 106 public void contextualize(Context context) throws ContextException 107 { 108 _context = context; 109 } 110 111 /** 112 * Transform the given {@link ContentSearchSort}s to compute joins and get {@link SortDefinition} 113 * @param contentSearcherSorts the sorts to transform 114 * @param contentTypeIds the identifiers of the content types where to search the field 115 * @return the sorts with computed joins to give to the {@link Searcher} 116 * @throws IllegalArgumentException if one of the given sort has not been found in the given content types 117 */ 118 public List<SortDefinition> transformContentSearcherSorts(List<ContentSearchSort> contentSearcherSorts, Set<String> contentTypeIds) throws IllegalArgumentException 119 { 120 List<SortDefinition> sort = new ArrayList<>(); 121 for (ContentSearchSort contentSearcherSort : contentSearcherSorts) 122 { 123 try 124 { 125 SortDefinition sortDefinition = getSortDefinition(contentSearcherSort.sortField(), contentTypeIds, contentSearcherSort.order()); 126 if (sortDefinition != null) 127 { 128 sort.add(sortDefinition); 129 } 130 else 131 { 132 getLogger().warn("The field '{}' is not sortable. The search will be executed, but without the sort on this field.", contentSearcherSort.sortField()); 133 } 134 } 135 catch (UndefinedItemPathException | BadItemTypeException e) 136 { 137 throw new IllegalArgumentException("The field '" + contentSearcherSort.sortField() + "' can't be found in the following content types " + StringUtils.join(contentTypeIds, ",") + ".", e); 138 } 139 } 140 141 return sort; 142 } 143 144 /** 145 * Retrieves the sort definition for the reference at the given path. 146 * @param referencePath the reference path 147 * @param contentTypeIds the identifiers of content types defining the reference 148 * @param order The sort order 149 * @return the sort field name for the reference at the given path. 150 * @throws UndefinedItemPathException if there is no item defined at the given path in given content types 151 * @throws BadItemTypeException if the definition of a part of the item path is not an item accessor 152 */ 153 public SortDefinition getSortDefinition(String referencePath, Set<String> contentTypeIds, SortOrder order) throws UndefinedItemPathException, BadItemTypeException 154 { 155 ElementDefinition reference = getReferenceFromFieldPath(referencePath, contentTypeIds); 156 if (!_isModelItemSortable(reference)) 157 { 158 return null; 159 } 160 161 JoinedPaths joinedPaths = computeJoinedPaths(referencePath, contentTypeIds); 162 String sortFieldName = joinedPaths.lastSegmentPrefix() + _getModelItemSortFieldName(reference); 163 164 return new SortDefinition(sortFieldName, joinedPaths.joinedPaths(), order); 165 } 166 167 private boolean _isModelItemSortable(ModelItem modelItem) 168 { 169 return modelItem instanceof IndexationAwareElementDefinition indexationAwareElementDefinition 170 ? indexationAwareElementDefinition.isSortable() 171 : modelItem instanceof Property 172 ? false 173 : modelItem.getType() instanceof SortableIndexableElementType; 174 } 175 176 private String _getModelItemSortFieldName(ModelItem modelItem) 177 { 178 return modelItem instanceof IndexationAwareElementDefinition indexationAwareElementDefinition 179 ? indexationAwareElementDefinition.getSolrSortFieldName() 180 : modelItem instanceof Property 181 ? null 182 : modelItem.getName() + ((SortableIndexableElementType) modelItem.getType()).getSortFieldSuffix(_getDataContext(modelItem)); 183 } 184 185 /** 186 * Retrieves the facet definition for the reference at the given path. 187 * @param referencePath the reference path 188 * @param contentTypeIds the identifiers of content types defining the reference 189 * @return the facet definition for the reference at the given path. 190 * @throws UndefinedItemPathException if there is no item defined at the given path in given content types 191 * @throws BadItemTypeException if the definition of a part of the item path is not an item accessor 192 */ 193 public FacetDefinition getFacetDefinition(String referencePath, Set<String> contentTypeIds) throws UndefinedItemPathException, BadItemTypeException 194 { 195 List<FacetDefinition> facetDefinitions = getFacetDefinitions(List.of(referencePath), contentTypeIds); 196 return facetDefinitions.isEmpty() ? null : facetDefinitions.get(0); 197 } 198 199 /** 200 * Retrieves the facet definitions for the references at the given paths. 201 * @param referencedPaths the references paths 202 * @param contentTypeIds the identifiers of content types defining the references 203 * @return the facet definitions for the references at the given paths. 204 */ 205 @SuppressWarnings("unchecked") 206 public List<FacetDefinition> getFacetDefinitions(List<String> referencedPaths, Set<String> contentTypeIds) 207 { 208 View facetedCriteria = new View(); 209 for (String facetFieldPath : referencedPaths) 210 { 211 try 212 { 213 ElementDefinition reference = getReferenceFromFieldPath(facetFieldPath, contentTypeIds); 214 if (_isModelItemFacetable(reference)) 215 { 216 ReferencingCriterionDefinition criterion = new ReferencingCriterionDefinition(reference, facetFieldPath); 217 criterion.setName(facetFieldPath); 218 criterion.setContentTypeIds(contentTypeIds); 219 220 ModelViewItem modelViewItem = new ViewElement(); 221 modelViewItem.setDefinition(criterion); 222 223 facetedCriteria.addViewItem(modelViewItem); 224 } 225 } 226 catch (UndefinedItemPathException | BadItemTypeException e) 227 { 228 throw new IllegalArgumentException("The field '" + facetFieldPath + "' can't be found in the following content types " + StringUtils.join(contentTypeIds, ",") + ".", e); 229 } 230 } 231 232 return _getFacetDefinitions(facetedCriteria, new HashMap<>()); 233 } 234 235 private boolean _isModelItemFacetable(ModelItem modelItem) 236 { 237 if (modelItem instanceof Property) 238 { 239 return modelItem instanceof CriterionDefinitionAwareElementDefinition 240 && modelItem instanceof IndexationAwareElementDefinition indexationAwareElementDefinition && indexationAwareElementDefinition.isFacetable(); 241 } 242 else 243 { 244 return ((IndexableElementType) modelItem.getType()).isFacetable(_getDataContext(modelItem)); 245 } 246 } 247 248 /** 249 * Retrieves the facet definitions for the given search model 250 * @param searchModel the search model 251 * @param contextualParameters the contextual parameters 252 * @return the facet definitions for the given search model 253 */ 254 public List<FacetDefinition> getFacetDefinitions(SearchModel searchModel, Map<String, Object> contextualParameters) 255 { 256 ViewItemAccessor facetedCriteria = searchModel.getFacetedCriteria(contextualParameters); 257 return _getFacetDefinitions(facetedCriteria, contextualParameters); 258 } 259 260 @SuppressWarnings("unchecked") 261 private List<FacetDefinition> _getFacetDefinitions(ViewItemAccessor viewItemAccessor, Map<String, Object> contextualParameters) 262 { 263 List<FacetDefinition> facetDefinitions = new ArrayList<>(); 264 265 for (ViewItem viewItem : viewItemAccessor.getViewItems()) 266 { 267 if (viewItem instanceof ModelViewItem modelViewItem 268 && modelViewItem.getDefinition() instanceof CriterionDefinition criterion) 269 { 270 String solrFacetFieldName = criterion.getSolrFacetFieldName(contextualParameters); 271 if (solrFacetFieldName != null) 272 { 273 String facetId = criterion.getName(); 274 List<String> joinedPaths = criterion.getJoinedPaths(contextualParameters); 275 facetDefinitions.add(new FacetDefinition(facetId, solrFacetFieldName, joinedPaths)); 276 } 277 } 278 279 if (viewItem instanceof ViewItemAccessor itemAccessor) 280 { 281 facetDefinitions.addAll(_getFacetDefinitions(itemAccessor, contextualParameters)); 282 } 283 } 284 285 return facetDefinitions; 286 } 287 288 private DataContext _getDataContext(ModelItem modelItem) 289 { 290 Locale locale = Optional.ofNullable(_context) 291 .map(c -> { 292 try 293 { 294 return ContextHelper.getRequest(c); 295 } 296 catch (RuntimeException e) 297 { 298 return null; 299 } 300 }) 301 .filter(Objects::nonNull) 302 .map(r -> r.getAttribute(SearchAction.SEARCH_LOCALE)) 303 .filter(Locale.class::isInstance) 304 .map(Locale.class::cast) 305 .orElse(null); 306 307 return DataContext.newInstance() 308 .withModelItem(modelItem) 309 .withLocale(locale); 310 } 311 312 /** 313 * Retrieves the reference with the given path 314 * @param referencePath the reference's path 315 * @param contentTypeIds the identifiers of the content types defining the reference 316 * @return the reference at the given path 317 * @throws UndefinedItemPathException if there is no model item in the given content types for the given path 318 * @throws BadItemTypeException if the model item at the given path is not an element 319 */ 320 public ElementDefinition getReferenceFromFieldPath(String referencePath, Set<String> contentTypeIds) throws UndefinedItemPathException 321 { 322 Set<ContentType> contentTypes = contentTypeIds.stream() 323 .map(_contentTypeExtensionPoint::getExtension) 324 .collect(Collectors.toSet()); 325 326 return _getReferenceFromFieldPath(referencePath, contentTypes); 327 } 328 329 private ElementDefinition _getReferenceFromFieldPath(String referencePath, Collection<ContentType> contentTypes) throws UndefinedItemPathException 330 { 331 String fieldName = referencePath.contains(ModelItem.ITEM_PATH_SEPARATOR) ? StringUtils.substringAfterLast(referencePath, ModelItem.ITEM_PATH_SEPARATOR) : referencePath; 332 ModelItem modelItem = _systemPropertyExtensionPoint.hasExtension(fieldName) 333 ? _systemPropertyExtensionPoint.getExtension(fieldName) 334 : !contentTypes.isEmpty() && ModelHelper.hasModelItem(referencePath, contentTypes) 335 ? ModelHelper.getModelItem(referencePath, contentTypes) 336 : referencePath.equals(Content.ATTRIBUTE_TITLE) 337 ? _contentTypesHelper.getTitleAttributeDefinition() 338 : null; 339 340 if (modelItem == null) 341 { 342 throw new UndefinedItemPathException("Unable to retrieve the reference at path '" + referencePath + "' in given content types"); 343 } 344 345 if (!(modelItem instanceof ElementDefinition reference)) 346 { 347 throw new BadItemTypeException("Unable to retrieve reference at path '" + referencePath + "'. It does not references an element but a group"); 348 } 349 350 return reference; 351 } 352 353 /** 354 * Compute the query field path for the given model item 355 * @param definition the definition 356 * @return the field path to use or query 357 */ 358 public String computeQueryFieldPath(ModelItem definition) 359 { 360 StringBuilder path = new StringBuilder(); 361 362 ModelItemGroup parent = definition.getParent(); 363 if (parent != null && !(parent instanceof RepeaterDefinition)) 364 { 365 path.append(computeQueryFieldPath(parent)).append(ModelItem.ITEM_PATH_SEPARATOR); 366 } 367 368 path.append(definition.getName()); 369 return path.toString(); 370 } 371 372 /** 373 * Retrieves the join paths from the given data path 374 * @param dataPath the data path 375 * @param contentTypeIds the ids of the content types containing the items of the given path 376 * @return the join paths 377 * @throws UndefinedItemPathException if there is no item defined at the given path in content types 378 * @throws BadItemTypeException if the definition of a part of the data path is not an item accessor 379 */ 380 public JoinedPaths computeJoinedPaths(String dataPath, Set<String> contentTypeIds) throws UndefinedItemPathException, BadItemTypeException 381 { 382 Set<ContentType> contentTypes = contentTypeIds.stream() 383 .map(_contentTypeExtensionPoint::getExtension) 384 .collect(Collectors.toSet()); 385 386 return _computeJoinedPaths(dataPath, StringUtils.EMPTY, contentTypes); 387 } 388 389 /** 390 * Retrieves the join paths from the given data path 391 * @param dataPath the data path 392 * @param prefix the prefix of current join path 393 * @param modelItemAccessors the model items to access the items of the given path 394 * @return the join paths 395 * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessors 396 * @throws BadItemTypeException if the definition of a part of the data path is not an item accessor 397 */ 398 private JoinedPaths _computeJoinedPaths(String dataPath, String prefix, Collection<? extends ModelItemAccessor> modelItemAccessors) throws UndefinedItemPathException, BadItemTypeException 399 { 400 String[] pathSegments = StringUtils.split(dataPath, ModelItem.ITEM_PATH_SEPARATOR); 401 if (pathSegments == null || pathSegments.length < 1) 402 { 403 throw new IllegalArgumentException("Unable to retrieve the data at the given path. This path is empty."); 404 } 405 else if (pathSegments.length == 1) 406 { 407 return new JoinedPaths(new ArrayList<>(), prefix); 408 } 409 else 410 { 411 String subDataPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length); 412 ModelItem modelItem = ModelHelper.getModelItem(pathSegments[0], modelItemAccessors); 413 if (modelItem instanceof ContentElementDefinition contentElementDefinition) 414 { 415 JoinedPaths joinPaths = _computeJoinedPaths(subDataPath, StringUtils.EMPTY, List.of(contentElementDefinition)); 416 joinPaths.joinedPaths().addFirst(prefix + pathSegments[0]); 417 return joinPaths; 418 } 419 else if (modelItem instanceof org.ametys.plugins.repository.model.RepeaterDefinition repeaterDefinition) 420 { 421 JoinedPaths joinPaths = _computeJoinedPaths(subDataPath, StringUtils.EMPTY, List.of(repeaterDefinition)); 422 joinPaths.joinedPaths().addFirst(prefix + pathSegments[0]); 423 return joinPaths; 424 } 425 else if (modelItem instanceof ModelItemAccessor modelItemAccessor) 426 { 427 return _computeJoinedPaths(subDataPath, pathSegments[0] + ModelItem.ITEM_PATH_SEPARATOR, List.of(modelItemAccessor)); 428 } 429 else 430 { 431 throw new BadItemTypeException("Unable to compute join paths for '" + dataPath + "'. The first segment of this path should be a group or a content element"); 432 } 433 } 434 } 435 436 /** 437 * Determines if the given model item represents an attribute of type content with contents with multilingual titles 438 * @param modelItem The model item 439 * @return <code>true</code> if the given model item represents an attribute of type content with contents with multilingual titles 440 */ 441 public boolean isTitleMultilingual(ModelItem modelItem) 442 { 443 return Optional.ofNullable(modelItem) 444 .filter(ContentElementDefinition.class::isInstance) 445 .map(ContentElementDefinition.class::cast) 446 .map(ContentElementDefinition::getContentTypeId) 447 .map(_contentTypeExtensionPoint::getExtension) 448 .filter(cType -> cType.hasModelItem(Content.ATTRIBUTE_TITLE)) 449 .map(cType -> cType.getModelItem(Content.ATTRIBUTE_TITLE)) 450 .map(ModelItem::getType) 451 .map(ModelItemType::getId) 452 .map(ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID::equals) 453 .orElse(false); 454 } 455 456 /** 457 * Record representing the computed joined paths from a reference 458 * @param joinedPaths the joined path 459 * @param lastSegmentPrefix the prefix of the las segment (used if the reference is in a composite) 460 */ 461 public record JoinedPaths(List<String> joinedPaths, String lastSegmentPrefix) { /* empty */ } 462}