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}