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