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}