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.Cache; 050import org.ametys.core.util.Cacheable; 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, Cacheable 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 _abstractCacheManager; 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 _abstractCacheManager = (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 createCaches(); 167 } 168 169 public AbstractCacheManager getCacheManager() 170 { 171 return _abstractCacheManager; 172 } 173 174 @Override 175 public Collection<SingleCacheConfiguration> getManagedCaches() 176 { 177 return Arrays.asList( 178 SingleCacheConfiguration.of( 179 __CRITERION_DEFINITION_CACHE_ID + _id, 180 _buildI18n("PLUGINS_WEB_SEARCH_CRITERION_CACHE_LABEL"), 181 _buildI18n("PLUGINS_WEB_SEARCH_CRITERION_CACHE_DESCRIPTION")) 182 ); 183 } 184 185 @Override 186 public boolean hasComputableSize() 187 { 188 return true; 189 } 190 191 private I18nizableText _buildI18n(String i18Key) 192 { 193 String catalogue = "plugin.web"; 194 Map<String, I18nizableTextParameter> params = Map.of("id", _label); 195 return new I18nizableText(catalogue, i18Key, params); 196 } 197 198 private Cache<String, Collection<CriterionDefinitionAndSourceContentType>> getCriterionDefinitionCache() 199 { 200 return getCacheManager().get(__CRITERION_DEFINITION_CACHE_ID + _id); 201 } 202 203 private SearchServiceCriterionDefinition _getSystemPropertyCriterionDefinition(String propertyId) 204 { 205 SystemProperty property = _systemPropertyEP.getExtension(propertyId); 206 String criterionDefinitionName = getCriterionDefinitionPrefix() + propertyId; 207 return _referencingSearchServiceCriterionDefinitionHelper.createReferencingSearchServiceCriterionDefinition(criterionDefinitionName, property, propertyId, this, _pluginName); 208 } 209 210 /** 211 * Gets the prefix for criterion definitions 212 * @return the prefix for criterion definitions 213 */ 214 protected abstract String getCriterionDefinitionPrefix(); 215 216 @Override 217 public void dispose() 218 { 219 _systemPropertyCriterionDefinitions.stream() 220 .filter(AbstractSearchServiceCriterionDefinition.class::isInstance) 221 .map(AbstractSearchServiceCriterionDefinition.class::cast) 222 .forEach(AbstractSearchServiceCriterionDefinition::dispose); 223 _systemPropertyCriterionDefinitions.clear(); 224 225 getCriterionDefinitionCache().asMap() 226 .values() 227 .stream() 228 .flatMap(Collection::stream) 229 .map(CriterionDefinitionAndSourceContentType::criterionDefinition) 230 .filter(AbstractSearchServiceCriterionDefinition.class::isInstance) 231 .map(AbstractSearchServiceCriterionDefinition.class::cast) 232 .forEach(AbstractSearchServiceCriterionDefinition::dispose); 233 getCriterionDefinitionCache().resetCache(); 234 removeCaches(); 235 } 236 237 @Override 238 public I18nizableText getLabel() 239 { 240 return _label; 241 } 242 243 @Override 244 public int criteriaPosition() 245 { 246 return _criteriaPosition; 247 } 248 249 @Override 250 public Collection<SearchServiceCriterionDefinition> getCriteria(AdditionalParameterValueMap additionalParameterValues) 251 { 252 Collection<SearchServiceCriterionDefinition> criteria = new ArrayList<>(); 253 254 // Content types 255 Set<String> contentTypeIds = getContentTypeIds(additionalParameterValues); 256 257 // Special case for title 258 criteria.add(_getTitleCriterionDefinition()); 259 260 // Model items from content types 261 Collection<CriterionDefinitionAndSourceContentType> modelItemCriterionDefinitions = _getModelItemCriterionDefinitions(contentTypeIds); 262 criteria.addAll(_finalModelItemCriterionDefinitions(modelItemCriterionDefinitions)); 263 264 // System properties 265 criteria.addAll(_systemPropertyCriterionDefinitions); 266 267 if (getLogger().isInfoEnabled()) 268 { 269 getLogger().info("#getCriteria for contentTypes '{}' returned '{}'", 270 contentTypeIds, 271 criteria.stream() 272 .map(SearchServiceCriterionDefinition::getName) 273 .collect(Collectors.toList())); 274 } 275 276 return criteria; 277 } 278 279 /** 280 * Gets the content type identifiers which will be used to retrieve the criteria 281 * @param additionalParameterValues The additional parameter values 282 * @return the content type identifiers which will be used to retrieve the criteria 283 */ 284 protected abstract Set<String> getContentTypeIds(AdditionalParameterValueMap additionalParameterValues); 285 286 private synchronized Collection<CriterionDefinitionAndSourceContentType> _getModelItemCriterionDefinitions(Set<String> contentTypeIds) 287 { 288 return contentTypeIds 289 .stream() 290 .map(this::_getModelItemCriterionDefinitions) 291 .flatMap(Collection::stream) 292 .collect(Collectors.toList()); 293 } 294 295 private Collection<CriterionDefinitionAndSourceContentType> _getModelItemCriterionDefinitions(String contentTypeId) 296 { 297 if (getCriterionDefinitionCache().hasKey(contentTypeId)) 298 { 299 // found in cache 300 Collection<CriterionDefinitionAndSourceContentType> defs = getCriterionDefinitionCache().get(contentTypeId); 301 getLogger().info("Search criteria for '{}' cache hit ({}).", contentTypeId, defs); 302 return defs; 303 } 304 else 305 { 306 // create criterion definitions 307 ContentType contentType = _contentTypeExtensionPoint.getExtension(contentTypeId); 308 if (contentType == null) 309 { 310 throw new IllegalArgumentException("Content type '" + contentTypeId + "' does not exist, cannot create model item criterion definitions."); 311 } 312 313 Collection<? extends ModelItem> modelItems = contentType.getModelItems(); 314 Collection<CriterionDefinitionAndSourceContentType> modelItemCriterionDefinitions = _createModelItemCriterionDefinitions(modelItems, contentType); 315 316 // add in cache 317 getCriterionDefinitionCache().put(contentTypeId, modelItemCriterionDefinitions); 318 getLogger().info("Search criteria for '{}' cache missed. They have been created and added in cache ({}).", contentTypeId, modelItemCriterionDefinitions); 319 return modelItemCriterionDefinitions; 320 } 321 } 322 323 private Collection<CriterionDefinitionAndSourceContentType> _createModelItemCriterionDefinitions(Collection<? extends ModelItem> modelItems, ContentType requestedContentType) 324 { 325 List<CriterionDefinitionAndSourceContentType> criteria = new ArrayList<>(); 326 final String prefix = getCriterionDefinitionPrefix(); 327 328 for (ModelItem modelItem : modelItems) 329 { 330 // Get only first-level field (ignore composites and repeaters) 331 if (modelItem instanceof ElementDefinition elementDefinition && !__EXCLUDED_MODEL_ITEMS.contains(modelItem.getName())) 332 { 333 ContentType fromContentType = Optional.ofNullable(modelItem.getModel()) 334 .filter(ContentType.class::isInstance) 335 .map(ContentType.class::cast) 336 .orElse(requestedContentType); 337 338 final String modelItemName = modelItem.getName(); 339 String criterionDefinitionName = prefix + fromContentType.getId() + "$" + modelItemName; 340 SearchServiceCriterionDefinition criterionDef = _referencingSearchServiceCriterionDefinitionHelper.createReferencingSearchServiceCriterionDefinition(criterionDefinitionName, elementDefinition, modelItemName, this, fromContentType, _pluginName); 341 if (criterionDef != null) 342 { 343 criteria.add(new CriterionDefinitionAndSourceContentType(criterionDef, fromContentType)); 344 } 345 } 346 } 347 348 criteria.sort(Comparator.comparing( 349 critDefAndSourceCtype -> critDefAndSourceCtype._contentTypeId, 350 new ContentTypeComparator(requestedContentType, _contentTypeExtensionPoint) 351 .reversed())); 352 return criteria; 353 } 354 355 @Override 356 public Query buildQuery( 357 AbstractTreeNode<SearchServiceCriterion<?>> criterionTree, 358 Map<String, Object> userCriteria, 359 Collection<Returnable> returnables, 360 Collection<Searchable> searchables, 361 AdditionalParameterValueMap additionalParameters, 362 String currentLang, 363 Map<String, Object> contextualParameters) 364 { 365 return _searchComponentHelper.buildQuery(criterionTree, userCriteria, returnables, searchables, additionalParameters, currentLang, null, contextualParameters); 366 } 367 368 private Collection<SearchServiceCriterionDefinition> _finalModelItemCriterionDefinitions(Collection<CriterionDefinitionAndSourceContentType> modelItemCriterionDefinitions) 369 { 370 return modelItemCriterionDefinitions 371 .stream() 372 // 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 373 // this is done by calling #distinct(), and thus this is based on the #equals impl of CriterionDefinitionAndSourceContentType 374 .distinct() 375 .map(CriterionDefinitionAndSourceContentType::criterionDefinition) 376 .collect(Collectors.toList()); 377 } 378 379 private synchronized SearchServiceCriterionDefinition _getTitleCriterionDefinition() 380 { 381 if (_titleCriterionDefinitionCache != null) 382 { 383 // found in cache 384 getLogger().info("'title' criterion definition cache hit."); 385 } 386 else 387 { 388 // create title criterion definition 389 String criterionDefinitionName = getTitleCriterionDefinitionName(); 390 AttributeDefinition<String> titleAttributeDefinition = _contentTypesHelper.getTitleAttributeDefinition(); 391 _titleCriterionDefinitionCache = _referencingSearchServiceCriterionDefinitionHelper.createReferencingSearchServiceCriterionDefinition(criterionDefinitionName, titleAttributeDefinition, Content.ATTRIBUTE_TITLE, this, _pluginName); 392 getLogger().info("'title' criterion definition cache missed. It has been created and added in cache."); 393 } 394 395 return _titleCriterionDefinitionCache; 396 } 397 398 /** 399 * Retrieves the name of the title criterion definition 400 * @return the name of the title criterion definition 401 */ 402 public String getTitleCriterionDefinitionName() 403 { 404 return getCriterionDefinitionPrefix() + "_common$" + Content.ATTRIBUTE_TITLE; 405 } 406 407 @Override 408 public Collection<Returnable> relationsWith() 409 { 410 return Arrays.asList(_pageReturnable, _associatedContentReturnable); 411 } 412 413 // wraps a CriterionDefinition and where it comes from 414 private static class CriterionDefinitionAndSourceContentType 415 { 416 String _contentTypeId; 417 @ExcludeFromSizeCalculation 418 private SearchServiceCriterionDefinition _criterionDefinition; 419 private String _criterionDefinitionName; 420 421 CriterionDefinitionAndSourceContentType(SearchServiceCriterionDefinition critDef, ContentType contentType) 422 { 423 _criterionDefinition = critDef; 424 _criterionDefinitionName = critDef.getName(); 425 _contentTypeId = contentType.getId(); 426 } 427 428 SearchServiceCriterionDefinition criterionDefinition() 429 { 430 return _criterionDefinition; 431 } 432 433 @Override 434 public String toString() 435 { 436 return _criterionDefinitionName; 437 } 438 439 @Override 440 public int hashCode() 441 { 442 final int prime = 31; 443 int result = 1; 444 result = prime * result + ((_contentTypeId == null) ? 0 : _contentTypeId.hashCode()); 445 result = prime * result + ((_criterionDefinitionName == null) ? 0 : _criterionDefinitionName.hashCode()); 446 return result; 447 } 448 449 @Override 450 public boolean equals(Object obj) 451 { 452 if (this == obj) 453 { 454 return true; 455 } 456 if (obj == null) 457 { 458 return false; 459 } 460 if (!(obj instanceof CriterionDefinitionAndSourceContentType)) 461 { 462 return false; 463 } 464 CriterionDefinitionAndSourceContentType other = (CriterionDefinitionAndSourceContentType) obj; 465 if (_contentTypeId == null) 466 { 467 if (other._contentTypeId != null) 468 { 469 return false; 470 } 471 } 472 else if (!_contentTypeId.equals(other._contentTypeId)) 473 { 474 return false; 475 } 476 if (_criterionDefinitionName == null) 477 { 478 if (other._criterionDefinitionName != null) 479 { 480 return false; 481 } 482 } 483 else if (!_criterionDefinitionName.equals(other._criterionDefinitionName)) 484 { 485 return false; 486 } 487 return true; 488 } 489 } 490 491 private static class ContentTypeComparator implements Comparator<String> 492 { 493 /* The purpose here is to fill a Map with an Integer 494 * for each content type id in the hierarchy, and to base 495 * the comparator on those values. 496 * For instance, if we have the following hierarchy: 497 * 498 * _______A______ 499 * _____/___\____ 500 * _____B____C___ 501 * ____/_\___|___ 502 * ___B1_B2__C1__ 503 * 504 * which means that <A extends B,C> & <B extends B1,B2> & <C extends C1> 505 * then we want to generate the Map: 506 * {A=1, B=2, B1=3, B2=4, C=5, C1=6} 507 * (which means we do a depth-first search with pre-order i.e. the children are processed after their parent, from left to right) 508 * (See also https://en.wikipedia.org/wiki/Tree_traversal#Pre-order_(NLR)) 509 * 510 * Then with this map, we generate the following order: 511 * [A, B, B1, B2, C, C1] 512 * (which will then be reversed by #_createModelItemCriterionDefinitions) 513 */ 514 String _baseCTypeId; 515 private Map<String, Integer> _orderByContentType; 516 517 ContentTypeComparator(ContentType baseContentType, ContentTypeExtensionPoint cTypeEP) 518 { 519 _baseCTypeId = baseContentType.getId(); 520 _orderByContentType = new HashMap<>(); 521 Incrementor incrementor = Incrementor.create() 522 .withStart(0) 523 .withMaximalCount(Integer.MAX_VALUE); 524 _fillOrderByContentType(baseContentType, incrementor, cTypeEP); 525 } 526 527 private void _fillOrderByContentType(ContentType contentType, Incrementor incrementor, ContentTypeExtensionPoint cTypeEP) 528 { 529 String contentTypeId = contentType.getId(); 530 incrementor.increment(); 531 _orderByContentType.put(contentTypeId, incrementor.getCount()); 532 Arrays.asList(contentType.getSupertypeIds()) 533 .stream() 534 .sequential() 535 .filter(id -> !_orderByContentType.containsKey(id)) // do not re-process already encountered content types 536 .map(cTypeEP::getExtension) 537 .filter(Objects::nonNull) 538 .forEachOrdered(childContentType -> _fillOrderByContentType(childContentType, incrementor, cTypeEP)); 539 } 540 541 @Override 542 public int compare(String c1ContentTypeId, String c2ContentTypeId) 543 { 544 if (c1ContentTypeId.equals(c2ContentTypeId)) 545 { 546 return 0; 547 } 548 549 if (!_orderByContentType.containsKey(c1ContentTypeId) || !_orderByContentType.containsKey(c2ContentTypeId)) 550 { 551 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()); 552 throw new IllegalStateException(message); 553 } 554 555 return Integer.compare(_orderByContentType.get(c1ContentTypeId), _orderByContentType.get(c2ContentTypeId)); 556 } 557 } 558 559}