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.cms.search.ui.model;
017
018import java.util.ArrayList;
019import java.util.List;
020import java.util.Optional;
021import java.util.Set;
022import java.util.stream.Collectors;
023import java.util.stream.Stream;
024
025import org.apache.avalon.framework.component.Component;
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.avalon.framework.service.Serviceable;
029import org.apache.commons.lang3.StringUtils;
030
031import org.ametys.cms.contenttype.ContentAttributeDefinition;
032import org.ametys.cms.contenttype.ContentType;
033import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
034import org.ametys.cms.contenttype.ContentTypesHelper;
035import org.ametys.cms.contenttype.MetadataDefinition;
036import org.ametys.cms.contenttype.MetadataType;
037import org.ametys.cms.contenttype.RepeaterDefinition;
038import org.ametys.cms.contenttype.indexing.IndexingField;
039import org.ametys.cms.contenttype.indexing.IndexingModel;
040import org.ametys.cms.contenttype.indexing.MetadataIndexingField;
041import org.ametys.cms.repository.Content;
042import org.ametys.cms.search.model.SystemPropertyExtensionPoint;
043import org.ametys.plugins.repository.data.type.ModelItemTypeConstants;
044import org.ametys.runtime.model.ModelHelper;
045import org.ametys.runtime.model.ModelItem;
046import org.ametys.runtime.model.ModelItemGroup;
047
048/**
049 * Component providing methods to manipulate {@link Column columns} for search models.
050 */
051public class ColumnHelper implements Component, Serviceable
052{
053    /** The Avalon role */
054    public static final String ROLE = ColumnHelper.class.getName();
055    
056    /** The content type extension point */
057    protected ContentTypeExtensionPoint _cTypeEP;
058    
059    /** The content type helper. */
060    protected ContentTypesHelper _cTypeHelper;
061    
062    /** The system property extension point. */
063    protected SystemPropertyExtensionPoint _systemPropEP;
064    
065    @Override
066    public void service(ServiceManager manager) throws ServiceException
067    {
068        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
069        _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
070        _systemPropEP = (SystemPropertyExtensionPoint) manager.lookup(SystemPropertyExtensionPoint.ROLE);
071    }
072    
073    /**
074     * From a string representing columns, returns the list of column ids with their (optional) labels.
075     * @param columnsStr The columns as a string
076     * @param contentTypeIds The common content type
077     * @return the list of column ids with their (optional) labels.
078     */
079    public List<Column> getColumns(String columnsStr, Set<String> contentTypeIds)
080    {
081        return _getColumns(List.of(StringUtils.split(columnsStr, ',')), contentTypeIds);
082    }
083    /**
084     * From a list of string representing columns, returns the list of column ids with their (optional) labels.
085     * @param columns The columns
086     * @param contentTypeIds The common content type
087     * @return the list of column ids with their (optional) labels.
088     */
089    public List<Column> getColumns(List<String> columns, Set<String> contentTypeIds)
090    {
091        return _getColumns(columns, contentTypeIds);
092    }
093    
094    private List<Column> _getColumns(List<String> columns, Set<String> contentTypeIds)
095    {
096        Set<ContentType> contentTypes = contentTypeIds.stream()
097                .map(_cTypeEP::getExtension)
098                .collect(Collectors.toSet());
099        
100        ColumnTransformer columnTransformer = new ColumnTransformer(contentTypes, this);
101        
102        // in StringUtils.split, adjacent separators are treated as one separator, so col cannot be empty
103        // but it still can be whitespaces only, just ignore them silently
104        return columns.stream()
105                      .filter(StringUtils::isNotBlank)
106                      .map(this::_leftTrim) // because we do not want a column named " as " to be split then, the "as" should be considered as the column id
107                      .map(col -> col.split("(?i) AS ", 2))
108                      .map(arr ->
109                      {
110                          // col is never empty, so arr.length cannot be 0
111                          String colId = arr[0].trim().replace('.', '/');
112                          if (arr.length == 2)
113                          {
114                              return new Column(colId, arr[1].trim());
115                          }
116                          else
117                          {
118                              return new Column(colId, null);
119                          }
120                      })
121                      .flatMap(columnTransformer::transform)
122                      .distinct()
123                      .collect(Collectors.toList());
124    }
125    
126    private String _leftTrim(String s)
127    {
128        return s.replaceAll("^\\s+", "");
129    }
130    
131    boolean isWildcardColumn(String path)
132    {
133        return "*".equals(path) || path.endsWith("/*");
134    }
135    
136    List<String> getWildcardAttributeColumnPaths(Set<ContentType> commonContentTypes, String attributePath) throws IllegalArgumentException
137    {
138        if (!isWildcardColumn(attributePath))
139        {
140            throw new IllegalArgumentException("The given attribute path '" + attributePath + "' does not have the correct syntax.");
141        }
142        
143        if (attributePath.endsWith("/*") && commonContentTypes.isEmpty())
144        {
145            throw new IllegalArgumentException("The given attribute path '" + attributePath + "' is invalid with an empty common content type (the path cannot be followed).");
146        }
147        
148        if (commonContentTypes.isEmpty() /* here we have metadataPath == "*" */)
149        {
150            // If no common ancestor, only title metadata is allowed
151            return List.of(Content.ATTRIBUTE_TITLE);
152        }
153        
154        if ("*".equals(attributePath))
155        {
156            Set<IndexingModel> indexingModels = commonContentTypes.stream()
157                    .map(ContentType::getIndexingModel)
158                    .collect(Collectors.toSet());
159            return _fieldNamesFromModels(indexingModels, "");
160        }
161        else
162        {
163            return _getFieldsForPath(attributePath, commonContentTypes);
164        }
165    }
166    
167    private List<String> _getFieldsForPath(String attributePath, Set<ContentType> commonContentTypes)
168    {
169        String parentPath = StringUtils.substringBeforeLast(attributePath, "/*");
170        ModelItem lastModelItem = ModelHelper.getModelItem(parentPath, commonContentTypes);
171        
172        if (lastModelItem instanceof ContentAttributeDefinition)
173        {
174            return _getFieldsForContentAttribute((ContentAttributeDefinition) lastModelItem, parentPath);
175        }
176        else if (lastModelItem instanceof ModelItemGroup)
177        {
178            return _getFieldsForGroup((ModelItemGroup) lastModelItem, parentPath);
179        }
180        else
181        {
182            throw new IllegalArgumentException("Invalid column definition '" + attributePath + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'");
183        }
184    }
185    
186    private List<String> _getFieldsForContentAttribute(ContentAttributeDefinition contentAttributeDefinition, String parentPath)
187    {
188        return Optional.of(contentAttributeDefinition)
189              .map(ContentAttributeDefinition::getContentTypeId)
190              .filter(_cTypeEP::hasExtension)
191              .map(_cTypeEP::getExtension)
192              .map(ContentType::getIndexingModel)
193              .map(indexingModel -> _fieldNamesFromModel(indexingModel, parentPath + "/"))
194              .orElse(List.of(parentPath + "/" + Content.ATTRIBUTE_TITLE));
195    }
196    
197    private List<String> _getFieldsForGroup(ModelItemGroup modelItemGroup, String parentPath)
198    {
199        final String prefix = parentPath + "/";
200        return modelItemGroup.getModelItems()
201                .stream()
202                .filter(this::_filterComposite)
203                .map(ModelItem::getName)
204                .map(prefix::concat)
205                .collect(Collectors.toList());
206    }
207    
208    private List<String> _fieldNamesFromModels(Set<IndexingModel> indexingModels, String prefix)
209    {
210        List<String> fieldNames = new ArrayList<>();
211        for (IndexingModel indexingModel : indexingModels)
212        {
213            List<String> fieldNamesFromModel = _fieldNamesFromModel(indexingModel, prefix);
214            fieldNames.addAll(fieldNamesFromModel);
215        }
216        return fieldNames;
217    }
218    
219    private List<String> _fieldNamesFromModel(IndexingModel indexingModel, String prefix)
220    {
221        return indexingModel.getFields()
222                .stream()
223                // Get only first-level metadata (ignore composites)
224                .filter(this::_filterComposite)
225                // Only metadata fields are displayable
226                .filter(MetadataIndexingField.class::isInstance)
227                .map(IndexingField::getName)
228                .map(prefix::concat)
229                .collect(Collectors.toList());
230    }
231    
232    private boolean _filterComposite(IndexingField field)
233    {
234        if (field instanceof MetadataIndexingField)
235        {
236            MetadataDefinition metaDef = ((MetadataIndexingField) field).getMetadataDefinition();
237            return metaDef != null && (metaDef.getType() != MetadataType.COMPOSITE || metaDef instanceof RepeaterDefinition);
238        }
239        return true;
240    }
241    
242    private boolean _filterComposite(ModelItem metaDef)
243    {
244        return metaDef != null && !(ModelItemTypeConstants.COMPOSITE_TYPE_ID.equals(metaDef.getType().getId()));
245    }
246    
247    List<String> getWildcardSystemColumnPaths(Set<ContentType> commonContentTypes, String path, boolean allowComposite)
248    {
249        if (!isWildcardColumn(path))
250        {
251            throw new IllegalArgumentException("The given path '" + path + "' does not have the correct syntax.");
252        }
253        
254        if ("*".equals(path))
255        {
256            return _systemPropEP.getDisplayProperties();
257        }
258        else if (commonContentTypes.isEmpty())
259        {
260            throw new IllegalArgumentException("The given path '" + path + "' is invalid with an empty common content type (the path cannot be followed).");
261        }
262        else
263        {
264            String parentPath = StringUtils.substringBeforeLast(path, "/*");
265            
266            if (!ModelHelper.hasModelItem(parentPath, commonContentTypes))
267            {
268                throw new IllegalArgumentException("Invalid column definition '" + path + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'");
269            }
270            
271            ModelItem lastModelItem = ModelHelper.getModelItem(parentPath, commonContentTypes);
272            if (lastModelItem instanceof ContentAttributeDefinition)
273            {
274                String basePath = parentPath + "/";
275                return _systemPropEP.getDisplayProperties()
276                        .stream()
277                        .map(prop -> basePath + prop)
278                        .collect(Collectors.toList());
279            }
280            else if (lastModelItem instanceof ModelItemGroup && allowComposite)
281            {
282                return List.of();
283            }
284            else
285            {
286                throw new IllegalArgumentException("Invalid column definition '" + path + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'");
287            }
288        }
289    }
290
291    private String _getJoinedContentTypeIds(Set<ContentType> contentTypes)
292    {
293        Set<String> commonContentTypeIds = contentTypes.stream()
294                .map(ContentType::getId)
295                .collect(Collectors.toSet());
296        return StringUtils.join(commonContentTypeIds, ", ");
297    }
298    
299    static final class ColumnTransformer
300    {
301        private Set<ContentType> _contentTypeIds;
302        private ColumnHelper _columnHelper;
303
304        ColumnTransformer(Set<ContentType> contentTypeIds, ColumnHelper columnHelper)
305        {
306            _contentTypeIds = contentTypeIds;
307            _columnHelper = columnHelper;
308        }
309        
310        Stream<Column> transform(Column column)
311        {
312            String colId = column.getId();
313            if (_columnHelper.isWildcardColumn(colId))
314            {
315                return Stream.concat(_metadataCols(colId), _systemCols(colId));
316            }
317            else
318            {
319                return Stream.of(column);
320            }
321        }
322        
323        private Stream<Column> _metadataCols(String colPath)
324        {
325            return _columnHelper.getWildcardAttributeColumnPaths(_contentTypeIds, colPath)
326                    .stream()
327                    .map(colId -> new Column(colId, null));
328        }
329        
330        private Stream<Column> _systemCols(String colPath)
331        {
332            return _columnHelper.getWildcardSystemColumnPaths(_contentTypeIds, colPath, true)
333                    .stream()
334                    .map(colId -> new Column(colId, null));
335        }
336    }
337    
338    /**
339     * A column and its (optional) label
340     */
341    public static final class Column
342    {
343        private String _id;
344        private Optional<String> _label;
345
346        /**
347         * Creates a {@link Column} object, wrapping a column id and its (optional) label.
348         * <br>If the provided label is <code>null</code>, then a default label will be applied to the column.
349         * @param columnId The id of the column
350         * @param columnLabel The label of the column. Cannot contain the comma character (<b>,</b>). Can be null
351         */
352        public Column(String columnId, String columnLabel)
353        {
354            _id = columnId;
355            _label = Optional.ofNullable(columnLabel);
356            if (_label.filter(l -> l.contains(",")).isPresent())
357            {
358                throw new IllegalArgumentException("The label cannot contain a comma.");
359            }
360        }
361        
362        /**
363        * Gets the id of the column
364        * @return the id of the column
365        */
366        public String getId()
367        {
368            return _id;
369        }
370        
371        /**
372        * Gets the label of the column
373        * @return the label of the column 
374        */
375        public Optional<String> getLabel()
376        {
377            return _label;
378        }
379        
380        @Override
381        public String toString()
382        {
383            return "Column<" + _id + ", \"" + _label.orElse("<NO LABEL>") + "\">";
384        }
385
386        @Override
387        public int hashCode()
388        {
389            final int prime = 31;
390            int result = 1;
391            result = prime * result + ((_id == null) ? 0 : _id.hashCode());
392            return result;
393        }
394
395        // only based on the column id
396        @Override
397        public boolean equals(Object obj)
398        {
399            if (this == obj)
400            {
401                return true;
402            }
403            if (obj == null)
404            {
405                return false;
406            }
407            if (getClass() != obj.getClass())
408            {
409                return false;
410            }
411            Column other = (Column) obj;
412            if (_id == null)
413            {
414                if (other._id != null)
415                {
416                    return false;
417                }
418            }
419            else if (!_id.equals(other._id))
420            {
421                return false;
422            }
423            return true;
424        }
425    }
426}