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}