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.SearchServiceCriterionDefinition;
059import org.ametys.web.frontoffice.search.metamodel.SearchServiceCriterionDefinitionHelper;
060import org.ametys.web.frontoffice.search.metamodel.Returnable;
061import org.ametys.web.frontoffice.search.metamodel.ReturnableExtensionPoint;
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            Collection<? extends ModelItem> modelItems = contentType.getModelItems();
309            Collection<CriterionDefinitionAndSourceContentType> modelItemCriterionDefinitions = _createModelItemCriterionDefinitions(modelItems, contentType);
310
311            // add in cache
312            getCriterionDefinitionCache().put(contentTypeId, modelItemCriterionDefinitions);
313            getLogger().info("Search criteria for '{}' cache missed. They have been created and added in cache ({}).", contentTypeId, modelItemCriterionDefinitions);
314            return modelItemCriterionDefinitions;
315        }
316    }
317    
318    private Collection<CriterionDefinitionAndSourceContentType> _createModelItemCriterionDefinitions(Collection<? extends ModelItem> modelItems, ContentType requestedContentType)
319    {
320        List<CriterionDefinitionAndSourceContentType> criteria = new ArrayList<>();
321        final String prefix = getCriterionDefinitionPrefix();
322
323        for (ModelItem modelItem : modelItems)
324        {
325            // Get only first-level field (ignore composites and repeaters)
326            if (modelItem instanceof ElementDefinition elementDefinition && !__EXCLUDED_MODEL_ITEMS.contains(modelItem.getName()))
327            {
328                ContentType fromContentType = Optional.ofNullable(modelItem.getModel())
329                                                      .filter(ContentType.class::isInstance)
330                                                      .map(ContentType.class::cast)
331                                                      .orElse(requestedContentType);
332                
333                final String modelItemName = modelItem.getName();
334                String criterionDefinitionName = prefix + fromContentType.getId() + "$" + modelItemName;
335                SearchServiceCriterionDefinition criterionDef = _referencingSearchServiceCriterionDefinitionHelper.createReferencingSearchServiceCriterionDefinition(criterionDefinitionName, elementDefinition, modelItemName, this, fromContentType, _pluginName);
336                if (criterionDef != null)
337                {
338                    criteria.add(new CriterionDefinitionAndSourceContentType(criterionDef, fromContentType));
339                }
340            }
341        }
342        
343        criteria.sort(Comparator.comparing(
344            critDefAndSourceCtype -> critDefAndSourceCtype._contentTypeId, 
345            new ContentTypeComparator(requestedContentType, _contentTypeExtensionPoint)
346                .reversed()));
347        return criteria;
348    }
349    
350    @Override
351    public Query buildQuery(
352            AbstractTreeNode<SearchServiceCriterion<?>> criterionTree, 
353            Map<String, Object> userCriteria, 
354            Collection<Returnable> returnables,
355            Collection<Searchable> searchables, 
356            AdditionalParameterValueMap additionalParameters, 
357            String currentLang, 
358            Map<String, Object> contextualParameters)
359    {
360        return _searchComponentHelper.buildQuery(criterionTree, userCriteria, returnables, searchables, additionalParameters, currentLang, null, contextualParameters);
361    }
362    
363    private Collection<SearchServiceCriterionDefinition> _finalModelItemCriterionDefinitions(Collection<CriterionDefinitionAndSourceContentType> modelItemCriterionDefinitions)
364    {
365        return modelItemCriterionDefinitions
366                .stream()
367                // 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
368                // this is done by calling #distinct(), and thus this is based on the #equals impl of CriterionDefinitionAndSourceContentType
369                .distinct()
370                .map(CriterionDefinitionAndSourceContentType::criterionDefinition)
371                .collect(Collectors.toList());
372    }
373    
374    private synchronized SearchServiceCriterionDefinition _getTitleCriterionDefinition()
375    {
376        if (_titleCriterionDefinitionCache != null)
377        {
378            // found in cache
379            getLogger().info("'title' criterion definition cache hit.");
380        }
381        else
382        {
383            // create title criterion definition
384            String criterionDefinitionName = getTitleCriterionDefinitionName();
385            AttributeDefinition<String> titleAttributeDefinition = _contentTypesHelper.getTitleAttributeDefinition();
386            _titleCriterionDefinitionCache = _referencingSearchServiceCriterionDefinitionHelper.createReferencingSearchServiceCriterionDefinition(criterionDefinitionName, titleAttributeDefinition, Content.ATTRIBUTE_TITLE, this, _pluginName);
387            getLogger().info("'title' criterion definition cache missed. It has been created and added in cache.");
388        }
389        
390        return _titleCriterionDefinitionCache;
391    }
392    
393    /**
394     * Retrieves the name of the title criterion definition
395     * @return the name of the title criterion definition
396     */
397    public String getTitleCriterionDefinitionName()
398    {
399        return getCriterionDefinitionPrefix() + "_common$" + Content.ATTRIBUTE_TITLE;
400    }
401    
402    @Override
403    public Collection<Returnable> relationsWith()
404    {
405        return Arrays.asList(_pageReturnable, _associatedContentReturnable);
406    }
407    
408    // wraps a CriterionDefinition and where it comes from
409    private static class CriterionDefinitionAndSourceContentType
410    {
411        String _contentTypeId;
412        @ExcludeFromSizeCalculation
413        private SearchServiceCriterionDefinition _criterionDefinition;
414        private String _criterionDefinitionName;
415        
416        CriterionDefinitionAndSourceContentType(SearchServiceCriterionDefinition critDef, ContentType contentType)
417        {
418            _criterionDefinition = critDef;
419            _criterionDefinitionName = critDef.getName();
420            _contentTypeId = contentType.getId();
421        }
422        
423        SearchServiceCriterionDefinition criterionDefinition()
424        {
425            return _criterionDefinition;
426        }
427        
428        @Override
429        public String toString()
430        {
431            return _criterionDefinitionName;
432        }
433
434        @Override
435        public int hashCode()
436        {
437            final int prime = 31;
438            int result = 1;
439            result = prime * result + ((_contentTypeId == null) ? 0 : _contentTypeId.hashCode());
440            result = prime * result + ((_criterionDefinitionName == null) ? 0 : _criterionDefinitionName.hashCode());
441            return result;
442        }
443
444        @Override
445        public boolean equals(Object obj)
446        {
447            if (this == obj)
448            {
449                return true;
450            }
451            if (obj == null)
452            {
453                return false;
454            }
455            if (!(obj instanceof CriterionDefinitionAndSourceContentType))
456            {
457                return false;
458            }
459            CriterionDefinitionAndSourceContentType other = (CriterionDefinitionAndSourceContentType) obj;
460            if (_contentTypeId == null)
461            {
462                if (other._contentTypeId != null)
463                {
464                    return false;
465                }
466            }
467            else if (!_contentTypeId.equals(other._contentTypeId))
468            {
469                return false;
470            }
471            if (_criterionDefinitionName == null)
472            {
473                if (other._criterionDefinitionName != null)
474                {
475                    return false;
476                }
477            }
478            else if (!_criterionDefinitionName.equals(other._criterionDefinitionName))
479            {
480                return false;
481            }
482            return true;
483        }
484    }
485    
486    private static class ContentTypeComparator implements Comparator<String>
487    {
488        /* The purpose here is to fill a Map with an Integer
489         * for each content type id in the hierarchy, and to base
490         * the comparator on those values.
491         * For instance, if we have the following hierarchy:
492         * 
493         * _______A______
494         * _____/___\____
495         * _____B____C___
496         * ____/_\___|___
497         * ___B1_B2__C1__
498         * 
499         * which means that <A extends B,C> & <B extends B1,B2> & <C extends C1>
500         * then we want to generate the Map:
501         * {A=1, B=2, B1=3, B2=4, C=5, C1=6}
502         * (which means we do a depth-first search with pre-order i.e. the children are processed after their parent, from left to right)
503         * (See also https://en.wikipedia.org/wiki/Tree_traversal#Pre-order_(NLR))
504         * 
505         * Then with this map, we generate the following order:
506         * [A, B, B1, B2, C, C1]
507         * (which will then be reversed by #_createModelItemCriterionDefinitions)
508         */
509        String _baseCTypeId;
510        private Map<String, Integer> _orderByContentType;
511        
512        ContentTypeComparator(ContentType baseContentType, ContentTypeExtensionPoint cTypeEP)
513        {
514            _baseCTypeId = baseContentType.getId();
515            _orderByContentType = new HashMap<>();
516            Incrementor incrementor = Incrementor.create()
517                    .withStart(0)
518                    .withMaximalCount(Integer.MAX_VALUE);
519            _fillOrderByContentType(baseContentType, incrementor, cTypeEP);
520        }
521        
522        private void _fillOrderByContentType(ContentType contentType, Incrementor incrementor, ContentTypeExtensionPoint cTypeEP)
523        {
524            String contentTypeId = contentType.getId();
525            incrementor.increment();
526            _orderByContentType.put(contentTypeId, incrementor.getCount());
527            Arrays.asList(contentType.getSupertypeIds())
528                    .stream()
529                    .sequential()
530                    .filter(id -> !_orderByContentType.containsKey(id)) // do not re-process already encountered content types
531                    .map(cTypeEP::getExtension)
532                    .filter(Objects::nonNull)
533                    .forEachOrdered(childContentType -> _fillOrderByContentType(childContentType, incrementor, cTypeEP));
534        }
535        
536        @Override
537        public int compare(String c1ContentTypeId, String c2ContentTypeId)
538        {
539            if (c1ContentTypeId.equals(c2ContentTypeId))
540            {
541                return 0;
542            }
543            
544            if (!_orderByContentType.containsKey(c1ContentTypeId) || !_orderByContentType.containsKey(c2ContentTypeId))
545            {
546                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());
547                throw new IllegalStateException(message);
548            }
549            
550            return Integer.compare(_orderByContentType.get(c1ContentTypeId), _orderByContentType.get(c2ContentTypeId));
551        }
552    }
553    
554}