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