001/* 002 * Copyright 2019 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.web.frontoffice.search.metamodel.impl; 017 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.Collection; 021import java.util.Comparator; 022import java.util.HashMap; 023import java.util.List; 024import java.util.Map; 025import java.util.Objects; 026import java.util.Optional; 027import java.util.Set; 028import java.util.stream.Collectors; 029 030import org.apache.avalon.framework.activity.Disposable; 031import org.apache.avalon.framework.activity.Initializable; 032import org.apache.avalon.framework.configuration.Configuration; 033import org.apache.avalon.framework.configuration.ConfigurationException; 034import org.apache.avalon.framework.service.ServiceException; 035import org.apache.avalon.framework.service.ServiceManager; 036import org.apache.commons.math3.util.IntegerSequence.Incrementor; 037 038import org.ametys.cms.content.ContentHelper; 039import org.ametys.cms.contenttype.AttributeDefinition; 040import org.ametys.cms.contenttype.ContentType; 041import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 042import org.ametys.cms.contenttype.ContentTypesHelper; 043import org.ametys.cms.repository.Content; 044import org.ametys.cms.search.advanced.AbstractTreeNode; 045import org.ametys.cms.search.model.SystemProperty; 046import org.ametys.cms.search.model.SystemPropertyExtensionPoint; 047import org.ametys.cms.search.query.Query; 048import org.ametys.core.cache.AbstractCacheManager; 049import org.ametys.core.cache.AbstractCacheManager.CacheType; 050import org.ametys.core.cache.Cache; 051import org.ametys.core.util.SizeUtils.ExcludeFromSizeCalculation; 052import org.ametys.runtime.i18n.I18nizableText; 053import org.ametys.runtime.i18n.I18nizableTextParameter; 054import org.ametys.runtime.model.ElementDefinition; 055import org.ametys.runtime.model.ModelItem; 056import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterion; 057import org.ametys.web.frontoffice.search.metamodel.AdditionalParameterValueMap; 058import org.ametys.web.frontoffice.search.metamodel.Returnable; 059import org.ametys.web.frontoffice.search.metamodel.ReturnableExtensionPoint; 060import org.ametys.web.frontoffice.search.metamodel.SearchServiceCriterionDefinition; 061import org.ametys.web.frontoffice.search.metamodel.SearchServiceCriterionDefinitionHelper; 062import org.ametys.web.frontoffice.search.metamodel.Searchable; 063import org.ametys.web.frontoffice.search.requesttime.impl.SearchComponentHelper; 064 065/** 066 * Abstract class for all {@link Searchable} based on {@link Content}s 067 */ 068public abstract class AbstractContentBasedSearchable extends AbstractParameterAdderSearchable implements Initializable, Disposable 069{ 070 // Push ids of System Properties you do not want to appear in the list 071 private static final List<String> __EXCLUDED_SYSTEM_PROPERTIES = Arrays.asList("site", 072 "parents", 073 "workflowStep"); 074 // Push ids of model items you do not want to appear in the list (for instance title which is handled separately) 075 private static final List<String> __EXCLUDED_MODEL_ITEMS = Arrays.asList("title"); 076 077 private static final String __CRITERION_DEFINITION_CACHE_ID = AbstractContentBasedSearchable.class.getName() + "$CriterionDefinitionCache"; 078 /** The id of extension point */ 079 protected String _id; 080 /** The label */ 081 protected I18nizableText _label; 082 /** The criteria position */ 083 protected int _criteriaPosition; 084 /** The page returnable */ 085 protected Returnable _pageReturnable; 086 /** The associated content returnable */ 087 protected Returnable _associatedContentReturnable; 088 089 /** The extension point for content types */ 090 protected ContentTypeExtensionPoint _contentTypeExtensionPoint; 091 092 /** The content helper */ 093 protected ContentHelper _contentHelper; 094 095 /** The content types helper */ 096 protected ContentTypesHelper _contentTypesHelper; 097 098 /** The search component helper */ 099 protected SearchComponentHelper _searchComponentHelper; 100 101 private ReturnableExtensionPoint _returnableEP; 102 private SystemPropertyExtensionPoint _systemPropertyEP; 103 private AbstractCacheManager _cacheManager; 104 private SearchServiceCriterionDefinitionHelper _referencingSearchServiceCriterionDefinitionHelper; 105 106 private List<SearchServiceCriterionDefinition> _systemPropertyCriterionDefinitions; 107 108 private SearchServiceCriterionDefinition _titleCriterionDefinitionCache; 109 110 @Override 111 public void configure(Configuration configuration) throws ConfigurationException 112 { 113 super.configure(configuration); 114 _id = configuration.getAttribute("id"); 115 _label = I18nizableText.parseI18nizableText(configuration.getChild("label"), "plugin." + _pluginName); 116 _criteriaPosition = configuration.getChild("criteriaPosition").getValueAsInteger(); 117 } 118 119 @Override 120 public void service(ServiceManager manager) throws ServiceException 121 { 122 super.service(manager); 123 _returnableEP = (ReturnableExtensionPoint) manager.lookup(ReturnableExtensionPoint.ROLE); 124 _pageReturnable = _returnableEP.getExtension(PageReturnable.ROLE); 125 _systemPropertyEP = (SystemPropertyExtensionPoint) manager.lookup(SystemPropertyExtensionPoint.ROLE); 126 _contentTypeExtensionPoint = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 127 _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE); 128 _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE); 129 _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 130 _searchComponentHelper = (SearchComponentHelper) manager.lookup(SearchComponentHelper.ROLE); 131 _referencingSearchServiceCriterionDefinitionHelper = (SearchServiceCriterionDefinitionHelper) manager.lookup(SearchServiceCriterionDefinitionHelper.ROLE); 132 } 133 134 /** 135 * Sets {@link #_associatedContentReturnable}. Called during {@link #initialize()} 136 */ 137 protected void _setAssociatedContentReturnable() 138 { 139 _associatedContentReturnable = _returnableEP.getExtension(associatedContentReturnableRole()); 140 } 141 142 /** 143 * The Avalon Role for the associated Content Returnable 144 * @return The Avalon Role for the associated Content Returnable 145 */ 146 protected abstract String associatedContentReturnableRole(); 147 148 @Override 149 public void initialize() throws Exception 150 { 151 _setAssociatedContentReturnable(); 152 153 _systemPropertyCriterionDefinitions = new ArrayList<>(); 154 for (String propId : _systemPropertyEP.getExtensionsIds()) 155 { 156 if (!__EXCLUDED_SYSTEM_PROPERTIES.contains(propId)) 157 { 158 SearchServiceCriterionDefinition def = _getSystemPropertyCriterionDefinition(propId); 159 if (def != null) 160 { 161 _systemPropertyCriterionDefinitions.add(def); 162 } 163 } 164 } 165 166 _cacheManager.createMemoryCache( 167 __CRITERION_DEFINITION_CACHE_ID + _id, 168 _buildI18n("PLUGINS_WEB_SEARCH_CRITERION_CACHE_LABEL"), 169 _buildI18n("PLUGINS_WEB_SEARCH_CRITERION_CACHE_DESCRIPTION"), 170 true, 171 null); 172 } 173 174 private I18nizableText _buildI18n(String i18Key) 175 { 176 String catalogue = "plugin.web"; 177 Map<String, I18nizableTextParameter> params = Map.of("id", _label); 178 return new I18nizableText(catalogue, i18Key, params); 179 } 180 181 private Cache<String, Collection<CriterionDefinitionAndSourceContentType>> getCriterionDefinitionCache() 182 { 183 return _cacheManager.get(__CRITERION_DEFINITION_CACHE_ID + _id); 184 } 185 186 private SearchServiceCriterionDefinition _getSystemPropertyCriterionDefinition(String propertyId) 187 { 188 SystemProperty property = _systemPropertyEP.getExtension(propertyId); 189 String criterionDefinitionName = getCriterionDefinitionPrefix() + propertyId; 190 return _referencingSearchServiceCriterionDefinitionHelper.createReferencingSearchServiceCriterionDefinition(criterionDefinitionName, property, propertyId, this, _pluginName); 191 } 192 193 /** 194 * Gets the prefix for criterion definitions 195 * @return the prefix for criterion definitions 196 */ 197 protected abstract String getCriterionDefinitionPrefix(); 198 199 @Override 200 public void dispose() 201 { 202 _systemPropertyCriterionDefinitions.stream() 203 .filter(AbstractSearchServiceCriterionDefinition.class::isInstance) 204 .map(AbstractSearchServiceCriterionDefinition.class::cast) 205 .forEach(AbstractSearchServiceCriterionDefinition::dispose); 206 _systemPropertyCriterionDefinitions.clear(); 207 208 getCriterionDefinitionCache().asMap() 209 .values() 210 .stream() 211 .flatMap(Collection::stream) 212 .map(CriterionDefinitionAndSourceContentType::criterionDefinition) 213 .filter(AbstractSearchServiceCriterionDefinition.class::isInstance) 214 .map(AbstractSearchServiceCriterionDefinition.class::cast) 215 .forEach(AbstractSearchServiceCriterionDefinition::dispose); 216 getCriterionDefinitionCache().resetCache(); 217 218 _cacheManager.removeCache(__CRITERION_DEFINITION_CACHE_ID + _id, CacheType.MEMORY); 219 } 220 221 @Override 222 public I18nizableText getLabel() 223 { 224 return _label; 225 } 226 227 @Override 228 public int criteriaPosition() 229 { 230 return _criteriaPosition; 231 } 232 233 @Override 234 public Collection<SearchServiceCriterionDefinition> getCriteria(AdditionalParameterValueMap additionalParameterValues) 235 { 236 Collection<SearchServiceCriterionDefinition> criteria = new ArrayList<>(); 237 238 // Content types 239 Set<String> contentTypeIds = getContentTypeIds(additionalParameterValues); 240 241 // Special case for title 242 criteria.add(_getTitleCriterionDefinition()); 243 244 // Model items from content types 245 Collection<CriterionDefinitionAndSourceContentType> modelItemCriterionDefinitions = _getModelItemCriterionDefinitions(contentTypeIds); 246 criteria.addAll(_finalModelItemCriterionDefinitions(modelItemCriterionDefinitions)); 247 248 // System properties 249 criteria.addAll(_systemPropertyCriterionDefinitions); 250 251 if (getLogger().isInfoEnabled()) 252 { 253 getLogger().info("#getCriteria for contentTypes '{}' returned '{}'", 254 contentTypeIds, 255 criteria.stream() 256 .map(SearchServiceCriterionDefinition::getName) 257 .collect(Collectors.toList())); 258 } 259 260 return criteria; 261 } 262 263 /** 264 * Gets the content type identifiers which will be used to retrieve the criteria 265 * @param additionalParameterValues The additional parameter values 266 * @return the content type identifiers which will be used to retrieve the criteria 267 */ 268 protected abstract Set<String> getContentTypeIds(AdditionalParameterValueMap additionalParameterValues); 269 270 private synchronized Collection<CriterionDefinitionAndSourceContentType> _getModelItemCriterionDefinitions(Set<String> contentTypeIds) 271 { 272 return contentTypeIds 273 .stream() 274 .map(this::_getModelItemCriterionDefinitions) 275 .flatMap(Collection::stream) 276 .collect(Collectors.toList()); 277 } 278 279 private Collection<CriterionDefinitionAndSourceContentType> _getModelItemCriterionDefinitions(String contentTypeId) 280 { 281 if (getCriterionDefinitionCache().hasKey(contentTypeId)) 282 { 283 // found in cache 284 Collection<CriterionDefinitionAndSourceContentType> defs = getCriterionDefinitionCache().get(contentTypeId); 285 getLogger().info("Search criteria for '{}' cache hit ({}).", contentTypeId, defs); 286 return defs; 287 } 288 else 289 { 290 // create criterion definitions 291 ContentType contentType = _contentTypeExtensionPoint.getExtension(contentTypeId); 292 if (contentType == null) 293 { 294 throw new IllegalArgumentException("Content type '" + contentTypeId + "' does not exist, cannot create model item criterion definitions."); 295 } 296 297 Collection<? extends ModelItem> modelItems = contentType.getModelItems(); 298 Collection<CriterionDefinitionAndSourceContentType> modelItemCriterionDefinitions = _createModelItemCriterionDefinitions(modelItems, contentType); 299 300 // add in cache 301 getCriterionDefinitionCache().put(contentTypeId, modelItemCriterionDefinitions); 302 getLogger().info("Search criteria for '{}' cache missed. They have been created and added in cache ({}).", contentTypeId, modelItemCriterionDefinitions); 303 return modelItemCriterionDefinitions; 304 } 305 } 306 307 private Collection<CriterionDefinitionAndSourceContentType> _createModelItemCriterionDefinitions(Collection<? extends ModelItem> modelItems, ContentType requestedContentType) 308 { 309 List<CriterionDefinitionAndSourceContentType> criteria = new ArrayList<>(); 310 final String prefix = getCriterionDefinitionPrefix(); 311 312 for (ModelItem modelItem : modelItems) 313 { 314 // Get only first-level field (ignore composites and repeaters) 315 if (modelItem instanceof ElementDefinition elementDefinition && !__EXCLUDED_MODEL_ITEMS.contains(modelItem.getName())) 316 { 317 ContentType fromContentType = Optional.ofNullable(modelItem.getModel()) 318 .filter(ContentType.class::isInstance) 319 .map(ContentType.class::cast) 320 .orElse(requestedContentType); 321 322 final String modelItemName = modelItem.getName(); 323 String criterionDefinitionName = prefix + fromContentType.getId() + "$" + modelItemName; 324 SearchServiceCriterionDefinition criterionDef = _referencingSearchServiceCriterionDefinitionHelper.createReferencingSearchServiceCriterionDefinition(criterionDefinitionName, elementDefinition, modelItemName, this, fromContentType, _pluginName); 325 if (criterionDef != null) 326 { 327 criteria.add(new CriterionDefinitionAndSourceContentType(criterionDef, fromContentType)); 328 } 329 } 330 } 331 332 criteria.sort(Comparator.comparing( 333 critDefAndSourceCtype -> critDefAndSourceCtype._contentTypeId, 334 new ContentTypeComparator(requestedContentType, _contentTypeExtensionPoint) 335 .reversed())); 336 return criteria; 337 } 338 339 @Override 340 public Query buildQuery( 341 AbstractTreeNode<SearchServiceCriterion<?>> criterionTree, 342 Map<String, Object> userCriteria, 343 Collection<Returnable> returnables, 344 Collection<Searchable> searchables, 345 AdditionalParameterValueMap additionalParameters, 346 String currentLang, 347 Map<String, Object> contextualParameters) 348 { 349 return _searchComponentHelper.buildQuery(criterionTree, userCriteria, returnables, searchables, additionalParameters, currentLang, null, contextualParameters); 350 } 351 352 private Collection<SearchServiceCriterionDefinition> _finalModelItemCriterionDefinitions(Collection<CriterionDefinitionAndSourceContentType> modelItemCriterionDefinitions) 353 { 354 return modelItemCriterionDefinitions 355 .stream() 356 // we want to not have duplicates, i.e. having same model item brought by two ContentTypes because it is declared in their common super ContentType 357 // this is done by calling #distinct(), and thus this is based on the #equals impl of CriterionDefinitionAndSourceContentType 358 .distinct() 359 .map(CriterionDefinitionAndSourceContentType::criterionDefinition) 360 .collect(Collectors.toList()); 361 } 362 363 private synchronized SearchServiceCriterionDefinition _getTitleCriterionDefinition() 364 { 365 if (_titleCriterionDefinitionCache != null) 366 { 367 // found in cache 368 getLogger().info("'title' criterion definition cache hit."); 369 } 370 else 371 { 372 // create title criterion definition 373 String criterionDefinitionName = getTitleCriterionDefinitionName(); 374 AttributeDefinition<String> titleAttributeDefinition = _contentTypesHelper.getTitleAttributeDefinition(); 375 _titleCriterionDefinitionCache = _referencingSearchServiceCriterionDefinitionHelper.createReferencingSearchServiceCriterionDefinition(criterionDefinitionName, titleAttributeDefinition, Content.ATTRIBUTE_TITLE, this, _pluginName); 376 getLogger().info("'title' criterion definition cache missed. It has been created and added in cache."); 377 } 378 379 return _titleCriterionDefinitionCache; 380 } 381 382 /** 383 * Retrieves the name of the title criterion definition 384 * @return the name of the title criterion definition 385 */ 386 public String getTitleCriterionDefinitionName() 387 { 388 return getCriterionDefinitionPrefix() + "_common$" + Content.ATTRIBUTE_TITLE; 389 } 390 391 @Override 392 public Collection<Returnable> relationsWith() 393 { 394 return Arrays.asList(_pageReturnable, _associatedContentReturnable); 395 } 396 397 // wraps a CriterionDefinition and where it comes from 398 private static class CriterionDefinitionAndSourceContentType 399 { 400 String _contentTypeId; 401 @ExcludeFromSizeCalculation 402 private SearchServiceCriterionDefinition _criterionDefinition; 403 private String _criterionDefinitionName; 404 405 CriterionDefinitionAndSourceContentType(SearchServiceCriterionDefinition critDef, ContentType contentType) 406 { 407 _criterionDefinition = critDef; 408 _criterionDefinitionName = critDef.getName(); 409 _contentTypeId = contentType.getId(); 410 } 411 412 SearchServiceCriterionDefinition criterionDefinition() 413 { 414 return _criterionDefinition; 415 } 416 417 @Override 418 public String toString() 419 { 420 return _criterionDefinitionName; 421 } 422 423 @Override 424 public int hashCode() 425 { 426 final int prime = 31; 427 int result = 1; 428 result = prime * result + ((_contentTypeId == null) ? 0 : _contentTypeId.hashCode()); 429 result = prime * result + ((_criterionDefinitionName == null) ? 0 : _criterionDefinitionName.hashCode()); 430 return result; 431 } 432 433 @Override 434 public boolean equals(Object obj) 435 { 436 if (this == obj) 437 { 438 return true; 439 } 440 if (obj == null) 441 { 442 return false; 443 } 444 if (!(obj instanceof CriterionDefinitionAndSourceContentType)) 445 { 446 return false; 447 } 448 CriterionDefinitionAndSourceContentType other = (CriterionDefinitionAndSourceContentType) obj; 449 if (_contentTypeId == null) 450 { 451 if (other._contentTypeId != null) 452 { 453 return false; 454 } 455 } 456 else if (!_contentTypeId.equals(other._contentTypeId)) 457 { 458 return false; 459 } 460 if (_criterionDefinitionName == null) 461 { 462 if (other._criterionDefinitionName != null) 463 { 464 return false; 465 } 466 } 467 else if (!_criterionDefinitionName.equals(other._criterionDefinitionName)) 468 { 469 return false; 470 } 471 return true; 472 } 473 } 474 475 private static class ContentTypeComparator implements Comparator<String> 476 { 477 /* The purpose here is to fill a Map with an Integer 478 * for each content type id in the hierarchy, and to base 479 * the comparator on those values. 480 * For instance, if we have the following hierarchy: 481 * 482 * _______A______ 483 * _____/___\____ 484 * _____B____C___ 485 * ____/_\___|___ 486 * ___B1_B2__C1__ 487 * 488 * which means that <A extends B,C> & <B extends B1,B2> & <C extends C1> 489 * then we want to generate the Map: 490 * {A=1, B=2, B1=3, B2=4, C=5, C1=6} 491 * (which means we do a depth-first search with pre-order i.e. the children are processed after their parent, from left to right) 492 * (See also https://en.wikipedia.org/wiki/Tree_traversal#Pre-order_(NLR)) 493 * 494 * Then with this map, we generate the following order: 495 * [A, B, B1, B2, C, C1] 496 * (which will then be reversed by #_createModelItemCriterionDefinitions) 497 */ 498 String _baseCTypeId; 499 private Map<String, Integer> _orderByContentType; 500 501 ContentTypeComparator(ContentType baseContentType, ContentTypeExtensionPoint cTypeEP) 502 { 503 _baseCTypeId = baseContentType.getId(); 504 _orderByContentType = new HashMap<>(); 505 Incrementor incrementor = Incrementor.create() 506 .withStart(0) 507 .withMaximalCount(Integer.MAX_VALUE); 508 _fillOrderByContentType(baseContentType, incrementor, cTypeEP); 509 } 510 511 private void _fillOrderByContentType(ContentType contentType, Incrementor incrementor, ContentTypeExtensionPoint cTypeEP) 512 { 513 String contentTypeId = contentType.getId(); 514 incrementor.increment(); 515 _orderByContentType.put(contentTypeId, incrementor.getCount()); 516 Arrays.asList(contentType.getSupertypeIds()) 517 .stream() 518 .sequential() 519 .filter(id -> !_orderByContentType.containsKey(id)) // do not re-process already encountered content types 520 .map(cTypeEP::getExtension) 521 .filter(Objects::nonNull) 522 .forEachOrdered(childContentType -> _fillOrderByContentType(childContentType, incrementor, cTypeEP)); 523 } 524 525 @Override 526 public int compare(String c1ContentTypeId, String c2ContentTypeId) 527 { 528 if (c1ContentTypeId.equals(c2ContentTypeId)) 529 { 530 return 0; 531 } 532 533 if (!_orderByContentType.containsKey(c1ContentTypeId) || !_orderByContentType.containsKey(c2ContentTypeId)) 534 { 535 String message = String.format("An unexpected error occured with the ContentType comparator for base '%s', cannot compare '%s' and '%s'.\nThe orderByContentType map is: %s", _baseCTypeId, c1ContentTypeId, c2ContentTypeId, _orderByContentType.toString()); 536 throw new IllegalStateException(message); 537 } 538 539 return Integer.compare(_orderByContentType.get(c1ContentTypeId), _orderByContentType.get(c2ContentTypeId)); 540 } 541 } 542 543}