001/*
002 *  Copyright 2025 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.cms.search.model.impl;
017
018import java.util.HashMap;
019import java.util.HashSet;
020import java.util.List;
021import java.util.Map;
022import java.util.Optional;
023import java.util.Set;
024import java.util.stream.Collectors;
025
026import org.apache.avalon.framework.configuration.Configuration;
027import org.apache.avalon.framework.configuration.ConfigurationException;
028import org.apache.avalon.framework.configuration.DefaultConfiguration;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.commons.lang3.StringUtils;
031
032import org.ametys.cms.contenttype.ContentType;
033import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
034import org.ametys.cms.data.type.ModelItemTypeExtensionPoint;
035import org.ametys.cms.data.type.indexing.IndexableElementType;
036import org.ametys.cms.model.CMSDataContext;
037import org.ametys.cms.model.ContentElementDefinition;
038import org.ametys.cms.model.properties.Property;
039import org.ametys.cms.search.content.ContentSearchHelper;
040import org.ametys.cms.search.content.ContentSearchHelper.JoinedPaths;
041import org.ametys.cms.search.model.CriterionDefinition;
042import org.ametys.cms.search.model.CriterionDefinitionAwareElementDefinition;
043import org.ametys.cms.search.model.IndexationAwareElementDefinition;
044import org.ametys.cms.search.query.JoinQuery;
045import org.ametys.cms.search.query.NotQuery;
046import org.ametys.cms.search.query.Query;
047import org.ametys.cms.search.query.Query.LogicalOperator;
048import org.ametys.cms.search.query.Query.Operator;
049import org.ametys.plugins.repository.model.RepeaterDefinition;
050import org.ametys.runtime.i18n.I18nizableText;
051import org.ametys.runtime.model.ElementDefinition;
052import org.ametys.runtime.model.Enumerator;
053import org.ametys.runtime.model.ModelHelper;
054import org.ametys.runtime.model.ModelItem;
055import org.ametys.runtime.model.ModelItemGroup;
056import org.ametys.runtime.model.exception.UnknownTypeException;
057import org.ametys.runtime.model.type.DataContext;
058import org.ametys.runtime.plugin.component.ThreadSafeComponentManager;
059
060/**
061 * Default implementation for {@link CriterionDefinition} searching on a model item.
062 * @param <T> Type of the criterion value
063 */
064public class ReferencingCriterionDefinition<T> extends AbstractCriterionDefinition<T>
065{
066    /** None value to search for reference where the attribute does not exist */
067    public static final String NONE_VALUE = "__ametys_none";
068    /** The content type extension point */
069    protected ContentTypeExtensionPoint _contentTypeExtensionPoint;
070    
071    /** The extension point containing all available criterion types */
072    protected ModelItemTypeExtensionPoint _criterionTypeExtensionPoint;
073    
074    /** The content search helper */
075    protected ContentSearchHelper _contentSearchHelper;
076    
077    /** The referenced definition used for this criterion */
078    protected ElementDefinition _reference;
079    
080    /** The path of the field from the containing search model */
081    protected String _referencePath;
082    
083    /** The identifiers of the content types defining the reference */
084    protected Set<String> _contentTypeIds = new HashSet<>();
085    
086    /**
087     * Default constructor.
088     */
089    public ReferencingCriterionDefinition()
090    {
091        // Nothing to do
092    }
093    
094    /**
095     * Constructor used to create a BO criterion definition on a referenced item
096     * @param reference the item referenced by this criterion
097     * @param referencePath the path of the criterion's reference
098     */
099    public ReferencingCriterionDefinition(ElementDefinition reference, String referencePath)
100    {
101        _reference = reference;
102        _referencePath = referencePath;
103    }
104    
105    /**
106     * Get the path in the criterion's reference.
107     * @return the path in the criterion's reference.
108     */
109    public String getReferencePath()
110    {
111        return _referencePath;
112    }
113    
114    /**
115     * Set the path of the criterion's reference
116     * @param referencePath the reference path to set
117     */
118    protected void setReferencePath(String referencePath)
119    {
120        _referencePath = referencePath;
121    }
122    
123    /**
124     * Retrieves the referenced element definition
125     * @return the referenced element definition
126     */
127    public ElementDefinition<T> getReference()
128    {
129        return _reference;
130    }
131
132    /**
133     * Set the referenced element definition
134     * @param reference the referenced element definition to set
135     */
136    protected void setReference(ElementDefinition<T> reference)
137    {
138        _reference = reference;
139    }
140    
141    /**
142     * Retrieves the identifiers of the content types defining the reference
143     * @param contextualParameters the contextual parameters
144     * @return the identifiers of the content types defining the reference
145     */
146    public Set<String> getContentTypeIds(Map<String, Object> contextualParameters)
147    {
148        return _contentTypeIds;
149    }
150    
151    /**
152     * Set the identifiers of the content types defining the reference
153     * @param contentTypeIds the content type identifiers to set
154     */
155    public void setContentTypeIds(Set<String> contentTypeIds)
156    {
157        _contentTypeIds = contentTypeIds;
158    }
159    
160    @Override
161    public I18nizableText getLabel()
162    {
163        I18nizableText label = super.getLabel();
164        return label != null ? label : getReference().getLabel();
165    }
166    
167    @Override
168    public I18nizableText getDescription()
169    {
170        I18nizableText description = super.getDescription();
171        return description != null ? description : getReference().getDescription();
172    }
173    
174    @SuppressWarnings("unchecked")
175    @Override
176    public IndexableElementType<T> getType()
177    {
178        if (getReference() instanceof CriterionDefinitionAwareElementDefinition criterionDefinitionAwareReference)
179        {
180            return criterionDefinitionAwareReference.getDefaultCriterionType();
181        }
182        else
183        {
184            IndexableElementType<T> referenceType = (IndexableElementType<T>) getReference().getType();
185            String criterionTypeId = referenceType.getDefaultCriterionTypeId(CMSDataContext.newInstance()
186                                                                                    .withModelItem(this));
187            
188            if (_getCriterionTypeExtensionPoint().hasExtension(criterionTypeId))
189            {
190                return (IndexableElementType<T>) _getCriterionTypeExtensionPoint().getExtension(criterionTypeId);
191            }
192            else
193            {
194                throw new UnknownTypeException("Unable to retrieve type of the criterion definition referencing '" + getReferencePath() + "'. The type '" + criterionTypeId + "' is not available for criteria");
195            }
196        }
197    }
198    
199    @Override
200    protected String _getDefaultWidget()
201    {
202        ElementDefinition<T> reference = getReference();
203        if (reference instanceof CriterionDefinitionAwareElementDefinition criterionDefinitionAwareReference)
204        {
205            String defaultWidget = criterionDefinitionAwareReference.getDefaultCriterionWidget();
206            return "edition.textarea".equals(defaultWidget) ? null : defaultWidget;
207        }
208        else
209        {
210            String defaultWidget = _getCriterionDefinitionHelper().getCriterionDefinitionDefaultWidget(this);
211            if (StringUtils.isEmpty(defaultWidget) && reference.isEditable())
212            {
213                defaultWidget = reference.getWidget();
214            }
215            
216            return defaultWidget;
217        }
218    }
219    
220    @Override
221    protected Map<String, I18nizableText> _getDefaultWidgetParameters()
222    {
223        ElementDefinition<T> reference = getReference();
224        if (reference instanceof CriterionDefinitionAwareElementDefinition criterionDefinitionAwareReference)
225        {
226            try
227            {
228                Configuration configuration = _getEnumeratorAndWidgetParamConf();
229                return criterionDefinitionAwareReference.getDefaultCriterionWidgetParameters(configuration);
230            }
231            catch (ConfigurationException e)
232            {
233                // An error occurs while trying to configure the criterion widget parameters of the system property => use the classic widget parameters
234                _logger.error("Unable to use widget parameters for criterion of the system property '" + criterionDefinitionAwareReference.getName() + "'", e);
235                return _getCriterionDefinitionHelper().getCriterionDefinitionDefaultWidgetParameters(this);
236            }
237        }
238        else
239        {
240            Map<String, I18nizableText> defaultWidgetParameters = new HashMap<>();
241            if (reference.isEditable())
242            {
243                defaultWidgetParameters.putAll(reference.getWidgetParameters());
244            }
245
246            defaultWidgetParameters.putAll(_getCriterionDefinitionHelper().getCriterionDefinitionDefaultWidgetParameters(this));
247            
248            return defaultWidgetParameters;
249        }
250    }
251    
252    @Override
253    public Enumerator<T> getEnumerator()
254    {
255        return Optional.ofNullable(super.getEnumerator())
256                       .orElseGet(() -> _getDefaultEnumerator());
257    }
258    
259    /**
260     * Retrieves the default {@link Enumerator} for the current criterion definition
261     * @return the default {@link Enumerator} for the current criterion definition
262     */
263    @SuppressWarnings("unchecked")
264    protected Enumerator<T> _getDefaultEnumerator()
265    {
266        ElementDefinition<T> reference = getReference();
267        if (reference instanceof CriterionDefinitionAwareElementDefinition criterionDefinitionAwareReference)
268        {
269            ThreadSafeComponentManager<Enumerator> enumeratorManager = new ThreadSafeComponentManager<>();
270            try
271            {
272                enumeratorManager.setLogger(_logger);
273                enumeratorManager.contextualize(__context);
274                enumeratorManager.service(__serviceManager);
275
276                Configuration configuration = _getEnumeratorAndWidgetParamConf();
277                return criterionDefinitionAwareReference.getDefaultCriterionEnumerator(configuration, enumeratorManager);
278            }
279            catch (ConfigurationException e)
280            {
281                // An error occurs while trying to configure the criterion enumerator of the reference => use the classic enumerator if exists
282                _logger.error("Unable to use enumerator for criterion of the reference '" + criterionDefinitionAwareReference.getName() + "'", e);
283                return reference.getEnumerator();
284            }
285            finally
286            {
287                enumeratorManager.dispose();
288                enumeratorManager = null;
289            }
290        }
291        else
292        {
293            return reference.getEnumerator();
294        }
295    }
296    
297    private Configuration _getEnumeratorAndWidgetParamConf() throws ConfigurationException
298    {
299        Set<String> contentTypeIds = new HashSet<>();
300        for (String contentTypeId : getContentTypeIds(new HashMap<>()))
301        {
302            contentTypeIds.add(contentTypeId);
303            contentTypeIds.addAll(_getContentTypeExtensionPoint().getSubTypes(contentTypeId));
304        }
305        Set<ContentType> contentTypes = contentTypeIds.stream()
306                                                      .map(_getContentTypeExtensionPoint()::getExtension)
307                                                      .collect(Collectors.toSet());
308        
309        String referencePath = getReferencePath();
310        if (referencePath.contains(ModelItem.ITEM_PATH_SEPARATOR))
311        {
312            String referencePathPrefix = StringUtils.substringBeforeLast(referencePath, ModelItem.ITEM_PATH_SEPARATOR);
313            ContentElementDefinition contentElementDefinition = (ContentElementDefinition) ModelHelper.getModelItem(referencePathPrefix, contentTypes);
314            String contentTypeId = contentElementDefinition.getContentTypeId();
315            contentTypes = Optional.ofNullable(contentTypeId)
316                                   .filter(StringUtils::isNotEmpty)
317                                   .map(id -> _getContentTypeExtensionPoint().getExtension(contentTypeId))
318                                   .map(contentType -> Set.of(contentType))
319                                   .orElseGet(() -> Set.of());
320        }
321        
322        Configuration wrap = _getCriterionDefinitionHelper().wrapCriterionConfiguration(_getRootCriterionConfiguration(), contentTypes);
323        
324        return wrap;
325    }
326    
327    /**
328     * Retrieves the root criterion configuration
329     * Used to wrap it for enumerator and widget parameters
330     * @return the root criterion configuration
331     */
332    protected Configuration _getRootCriterionConfiguration()
333    {
334        return new DefaultConfiguration("criterion");
335    }
336    
337    @SuppressWarnings("unchecked")
338    @Override
339    public Object convertQueryValue(Object value, Map<String, Object> contextualParameters)
340    {
341        return getReference() instanceof CriterionDefinitionAwareElementDefinition criterionDefinitionAwareReference
342            ? criterionDefinitionAwareReference.convertQueryValue(value, getReferencePath(), contextualParameters)
343            : super.convertQueryValue(value, contextualParameters);
344    }
345    
346    @SuppressWarnings("unchecked")
347    @Override
348    public Query getQuery(Object value, Operator operator, Map<String, Object> allValues, String language, Map<String, Object> contextualParameters)
349    {
350        if (operator != Operator.EXISTS && _getCriterionDefinitionHelper().isQueryValueEmpty(value))
351        {
352            return null;
353        }
354
355        ElementDefinition<T> reference = getReference();
356        Query query = null;
357        if (reference instanceof CriterionDefinitionAwareElementDefinition criterionDefinitionAwareReference)
358        {
359            // If value wanted is none, create query that search for contents with no value for the attribute
360            if (NONE_VALUE.equals(value))
361            {
362                query = new NotQuery(criterionDefinitionAwareReference.getQuery(value, Operator.EXISTS, language, contextualParameters));
363            }
364            else
365            {
366                query = criterionDefinitionAwareReference.getQuery(value, operator, language, contextualParameters);
367            }
368        }
369        else
370        {
371            boolean isMultipleOperandAnd = LogicalOperator.AND.equals(getMultipleOperandOperator());
372            CMSDataContext context = getQueryContext(language, contextualParameters);
373            
374            IndexableElementType referenceType = (IndexableElementType) reference.getType();
375            String queryFieldPath = _computeFieldPath(reference);
376            
377            // If value wanted is none, create query that search for contents with no value for the attribute
378            if (NONE_VALUE.equals(value))
379            {
380                query = new NotQuery(referenceType.getDefaultQuery(value, queryFieldPath, Operator.EXISTS, isMultipleOperandAnd, context));
381            }
382            else
383            {
384                query = referenceType.getDefaultQuery(value, queryFieldPath, operator, isMultipleOperandAnd, context);
385            }
386        }
387        
388        List<String> queryJoinPaths = getJoinedPaths(contextualParameters);
389        if (query != null && !queryJoinPaths.isEmpty())
390        {
391            query = new JoinQuery(query, queryJoinPaths);
392        }
393        
394        return query;
395    }
396    
397    private String _computeFieldPath(ModelItem definition)
398    {
399        StringBuilder path = new StringBuilder();
400        
401        ModelItemGroup parent = definition.getParent();
402        if (parent != null && !(parent instanceof RepeaterDefinition))
403        {
404            path.append(_computeFieldPath(parent)).append(ModelItem.ITEM_PATH_SEPARATOR);
405        }
406        
407        path.append(definition.getName());
408        return path.toString();
409    }
410    
411    @Override
412    public String getSolrFacetFieldName(Map<String, Object> contextualParameters)
413    {
414        String solrFacetFieldName = super.getSolrFacetFieldName(contextualParameters);
415        
416        if (solrFacetFieldName == null && _isFacetable())
417        {
418            Set<String> contentTypeIds = getContentTypeIds(contextualParameters);
419            JoinedPaths joinedPaths =  _getContentSearchHelper().computeJoinedPaths(getReferencePath(), contentTypeIds);
420            String fieldNameSuffix = _getFacetFieldNameSuffix();
421            solrFacetFieldName = joinedPaths.lastSegmentPrefix() + fieldNameSuffix;
422            setSolrFacetFieldName(solrFacetFieldName);
423        }
424
425        return solrFacetFieldName;
426    }
427    
428    /**
429     * Determine if the current criterion definition can be used as facet
430     * @return <code>true</code> if the current criterion definition can be used as facet, <code>false</code> otherwise
431     */
432    protected boolean _isFacetable()
433    {
434        ElementDefinition<T> reference = getReference();
435        return reference instanceof IndexationAwareElementDefinition indexationAwareElementDefinition
436                ? indexationAwareElementDefinition.isFacetable()
437                : reference instanceof Property
438                    ? false
439                    : ((IndexableElementType) reference.getType()).isFacetable(DataContext.newInstance()
440                                                       .withModelItem(this));
441    }
442    
443    /**
444     * Retrieves the suffix of the facet field name
445     * @return the suffix of the facet field name
446     */
447    protected String _getFacetFieldNameSuffix()
448    {
449        ElementDefinition<T> reference = getReference();
450        return reference instanceof IndexationAwareElementDefinition indexationAwareElementDefinition
451                ? indexationAwareElementDefinition.getSolrFacetFieldName()
452                : reference instanceof Property
453                    ? null
454                    : reference.getName() + ((IndexableElementType) reference.getType()).getFacetFieldSuffix(DataContext.newInstance()
455                                                                                                                        .withModelItem(reference));
456    }
457    
458    @Override
459    public List<String> getJoinedPaths(Map<String, Object> contextualParameters)
460    {
461        List<String> joinedPaths = super.getJoinedPaths(contextualParameters);
462        
463        if (joinedPaths.isEmpty())
464        {
465            Set<String> contentTypeIds = getContentTypeIds(contextualParameters);
466            JoinedPaths computedJoinedPaths = _getContentSearchHelper().computeJoinedPaths(getReferencePath(), contentTypeIds);
467            joinedPaths = computedJoinedPaths.joinedPaths();
468            setJoinedPaths(joinedPaths);
469        }
470
471        return joinedPaths;
472    }
473    
474    /**
475     * Retrieves The content type extension point
476     * @return The content type extension point
477     */
478    protected ContentTypeExtensionPoint _getContentTypeExtensionPoint()
479    {
480        if (_contentTypeExtensionPoint == null)
481        {
482            try
483            {
484                _contentTypeExtensionPoint = (ContentTypeExtensionPoint) __serviceManager.lookup(ContentTypeExtensionPoint.ROLE);
485            }
486            catch (ServiceException e)
487            {
488                throw new RuntimeException("Unable to lookup after the content type extension point", e);
489            }
490        }
491        
492        return _contentTypeExtensionPoint;
493    }
494    
495    /**
496     * Retrieves The extension point containing all available criterion types
497     * @return The extension point containing all available criterion types
498     */
499    protected ModelItemTypeExtensionPoint _getCriterionTypeExtensionPoint()
500    {
501        if (_criterionTypeExtensionPoint == null)
502        {
503            try
504            {
505                _criterionTypeExtensionPoint = (ModelItemTypeExtensionPoint) __serviceManager.lookup(ModelItemTypeExtensionPoint.ROLE_CRITERION_DEFINITION);
506            }
507            catch (ServiceException e)
508            {
509                throw new RuntimeException("Unable to lookup after the extension point containing all available criterion types", e);
510            }
511        }
512        
513        return _criterionTypeExtensionPoint;
514    }
515    
516    /**
517     * Retrieves the content search helper
518     * @return the content search helper
519     */
520    protected ContentSearchHelper _getContentSearchHelper()
521    {
522        if (_contentSearchHelper == null)
523        {
524            try
525            {
526                _contentSearchHelper = (ContentSearchHelper) __serviceManager.lookup(ContentSearchHelper.ROLE);
527            }
528            catch (ServiceException e)
529            {
530                throw new RuntimeException("Unable to lookup after the content search helper", e);
531            }
532        }
533        
534        return _contentSearchHelper;
535    }
536}