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}