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.regex.Matcher; 024import java.util.regex.Pattern; 025import java.util.stream.Collectors; 026import java.util.stream.Stream; 027 028import org.apache.avalon.framework.component.Component; 029import org.apache.avalon.framework.service.ServiceException; 030import org.apache.avalon.framework.service.ServiceManager; 031import org.apache.avalon.framework.service.Serviceable; 032import org.apache.commons.lang3.StringUtils; 033 034import org.ametys.cms.contenttype.ContentAttributeDefinition; 035import org.ametys.cms.contenttype.ContentType; 036import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 037import org.ametys.cms.contenttype.ContentTypesHelper; 038import org.ametys.cms.repository.Content; 039import org.ametys.cms.search.model.SystemPropertyExtensionPoint; 040import org.ametys.plugins.repository.model.CompositeDefinition; 041import org.ametys.plugins.repository.model.ViewHelper; 042import org.ametys.runtime.i18n.I18nizableText; 043import org.ametys.runtime.model.ModelHelper; 044import org.ametys.runtime.model.ModelItem; 045import org.ametys.runtime.model.ModelItemAccessor; 046import org.ametys.runtime.model.ModelItemContainer; 047import org.ametys.runtime.model.ModelItemGroup; 048import org.ametys.runtime.model.ModelViewItem; 049import org.ametys.runtime.model.View; 050import org.ametys.runtime.model.ViewItem; 051import org.ametys.runtime.model.ViewItemAccessor; 052 053/** 054 * Component providing methods to manipulate {@link Column columns} for search models. 055 */ 056public class ColumnHelper implements Component, Serviceable 057{ 058 /** The Avalon role */ 059 public static final String ROLE = ColumnHelper.class.getName(); 060 061 private static final String __VIEW_REFERENCE_REGEX = "\\[(.+)\\]"; 062 private static final Pattern __VIEW_REFERENCE_PATTERN = Pattern.compile("^[^\\[]+" + __VIEW_REFERENCE_REGEX + "$"); 063 064 /** The content type extension point */ 065 protected ContentTypeExtensionPoint _cTypeEP; 066 067 /** The content type helper. */ 068 protected ContentTypesHelper _cTypeHelper; 069 070 /** The system property extension point. */ 071 protected SystemPropertyExtensionPoint _systemPropEP; 072 073 @Override 074 public void service(ServiceManager manager) throws ServiceException 075 { 076 _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 077 _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 078 _systemPropEP = (SystemPropertyExtensionPoint) manager.lookup(SystemPropertyExtensionPoint.ROLE); 079 } 080 081 /** 082 * From a string representing columns, returns the list of column ids with their (optional) labels. 083 * @param columnsStr The columns as a string 084 * @param contentTypeIds The common content type 085 * @return the list of column ids with their (optional) labels. 086 */ 087 public List<Column> getColumns(String columnsStr, Set<String> contentTypeIds) 088 { 089 return _getColumns(List.of(StringUtils.split(columnsStr, ',')), contentTypeIds); 090 } 091 /** 092 * From a list of string representing columns, returns the list of column ids with their (optional) labels. 093 * @param columns The columns 094 * @param contentTypeIds The common content type 095 * @return the list of column ids with their (optional) labels. 096 */ 097 public List<Column> getColumns(List<String> columns, Set<String> contentTypeIds) 098 { 099 return _getColumns(columns, contentTypeIds); 100 } 101 102 private List<Column> _getColumns(List<String> columns, Set<String> contentTypeIds) 103 { 104 Set<ContentType> contentTypes = contentTypeIds.stream() 105 .map(_cTypeEP::getExtension) 106 .collect(Collectors.toSet()); 107 108 ColumnTransformer columnTransformer = new ColumnTransformer(contentTypes, this); 109 110 // in StringUtils.split, adjacent separators are treated as one separator, so col cannot be empty 111 // but it still can be whitespaces only, just ignore them silently 112 return columns.stream() 113 .filter(StringUtils::isNotBlank) 114 .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 115 .map(col -> col.split("(?i) AS ", 2)) 116 .map(arr -> 117 { 118 // col is never empty, so arr.length cannot be 0 119 String colId = arr[0].trim().replace('.', '/'); 120 if (arr.length == 2) 121 { 122 return new Column(colId, arr[1].trim()); 123 } 124 else 125 { 126 return new Column(colId, null); 127 } 128 }) 129 .flatMap(columnTransformer::transform) 130 .distinct() 131 .collect(Collectors.toList()); 132 } 133 134 private String _leftTrim(String s) 135 { 136 return s.replaceAll("^\\s+", ""); 137 } 138 139 boolean isViewReference(String path) 140 { 141 Matcher matcher = __VIEW_REFERENCE_PATTERN.matcher(path); 142 return matcher.matches(); 143 } 144 145 List<String> getViewReferenceColumnPaths(Set<ContentType> commonContentTypes, String attributePath) 146 { 147 if (commonContentTypes.isEmpty()) 148 { 149 throw new IllegalArgumentException("The given attribute path '" + attributePath + "' is invalid with an empty common content type (the path cannot be followed)."); 150 } 151 152 Matcher matcher = __VIEW_REFERENCE_PATTERN.matcher(attributePath); 153 if (!matcher.matches()) 154 { 155 throw new IllegalArgumentException("The given attribute path '" + attributePath + "' does not have the correct syntax."); 156 } 157 String viewName = matcher.group(1); 158 159 // Get the attribute without the view reference 160 String path = attributePath.replaceAll(__VIEW_REFERENCE_REGEX, StringUtils.EMPTY); 161 if (!ModelHelper.hasModelItem(path, commonContentTypes)) 162 { 163 throw new IllegalArgumentException("The given attribute path '" + path + "' is not defined in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'."); 164 } 165 166 ModelItem modelItem = ModelHelper.getModelItem(path, commonContentTypes); 167 if (!(modelItem instanceof ContentAttributeDefinition)) 168 { 169 throw new IllegalArgumentException("The given attribute path '" + path + "' type is not content in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'."); 170 } 171 172 ContentAttributeDefinition definition = (ContentAttributeDefinition) modelItem; 173 String contentTypeId = definition.getContentTypeId(); 174 if (StringUtils.isEmpty(contentTypeId)) 175 { 176 throw new IllegalArgumentException("The attribute at path '" + path + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "' does not specify any content type."); 177 } 178 179 ContentType contentType = _cTypeEP.getExtension(contentTypeId); 180 View referencedView = contentType.getView(viewName); 181 if (referencedView == null) 182 { 183 throw new IllegalArgumentException("The view '" + viewName + "' does not exist in content type '" + contentTypeId + "'."); 184 } 185 186 // Build the list of paths from the referenced view 187 return _getColumnPathsFromViewItemAccessor(referencedView, path + ModelItem.ITEM_PATH_SEPARATOR); 188 } 189 190 private List<String> _getColumnPathsFromViewItemAccessor(ViewItemAccessor viewItemAccessor, String prefix) 191 { 192 List<String> columnPaths = new ArrayList<>(); 193 194 ViewHelper.visitView(viewItemAccessor, 195 (element, definition) -> { 196 // simple element 197 String columnPath = prefix + definition.getName(); 198 199 if (element instanceof ViewItemAccessor accessor && !accessor.getViewItems().isEmpty()) 200 { 201 String newPrefix = columnPath + ModelItem.ITEM_PATH_SEPARATOR; 202 columnPaths.addAll(_getColumnPathsFromViewItemAccessor(accessor, newPrefix)); 203 } 204 else 205 { 206 columnPaths.add(columnPath); 207 } 208 }, 209 (group, definition) -> { 210 // composite 211 if (!group.getViewItems().isEmpty()) 212 { 213 String newPrefix = prefix + definition.getName() + ModelItem.ITEM_PATH_SEPARATOR; 214 columnPaths.addAll(_getColumnPathsFromViewItemAccessor(group, newPrefix)); 215 } 216 }, 217 (group, definition) -> { 218 // repeater 219 columnPaths.add(prefix + definition.getName()); 220 }, 221 group -> columnPaths.addAll(_getColumnPathsFromViewItemAccessor(group, prefix))); 222 223 return columnPaths; 224 } 225 226 boolean isWildcardColumn(String path) 227 { 228 return "*".equals(path) || path.endsWith("/*"); 229 } 230 231 List<String> getWildcardAttributeColumnPaths(Set<ContentType> commonContentTypes, String attributePath) throws IllegalArgumentException 232 { 233 if (!isWildcardColumn(attributePath)) 234 { 235 throw new IllegalArgumentException("The given attribute path '" + attributePath + "' does not have the correct syntax."); 236 } 237 238 if (attributePath.endsWith("/*") && commonContentTypes.isEmpty()) 239 { 240 throw new IllegalArgumentException("The given attribute path '" + attributePath + "' is invalid with an empty common content type (the path cannot be followed)."); 241 } 242 243 if (commonContentTypes.isEmpty() /* here we have itemPath == "*" */) 244 { 245 // If no common ancestor, only title attribute is allowed 246 return List.of(Content.ATTRIBUTE_TITLE); 247 } 248 249 if ("*".equals(attributePath)) 250 { 251 return _fieldNamesFromContentTypes(commonContentTypes); 252 } 253 else 254 { 255 return _getFieldsForPath(attributePath, commonContentTypes); 256 } 257 } 258 259 private List<String> _getFieldsForPath(String attributePath, Set<ContentType> commonContentTypes) 260 { 261 String parentPath = StringUtils.substringBeforeLast(attributePath, "/*"); 262 ModelItem lastModelItem = ModelHelper.getModelItem(parentPath, commonContentTypes); 263 264 if (lastModelItem instanceof ContentAttributeDefinition contentAttributeDefinition) 265 { 266 return _getFieldsForContentAttribute(contentAttributeDefinition, parentPath); 267 } 268 else if (lastModelItem instanceof ModelItemGroup modelItemGroup) 269 { 270 return _fieldNamesFromModelItemContainer(modelItemGroup, parentPath + "/"); 271 } 272 else 273 { 274 throw new IllegalArgumentException("Invalid column definition '" + attributePath + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'"); 275 } 276 } 277 278 private List<String> _getFieldsForContentAttribute(ContentAttributeDefinition contentAttributeDefinition, String parentPath) 279 { 280 return Optional.of(contentAttributeDefinition) 281 .map(ContentAttributeDefinition::getContentTypeId) 282 .filter(_cTypeEP::hasExtension) 283 .map(_cTypeEP::getExtension) 284 .map(contentType -> _fieldNamesFromModelItemContainer(contentType, parentPath + ModelItem.ITEM_PATH_SEPARATOR)) 285 .orElse(List.of(parentPath + "/" + Content.ATTRIBUTE_TITLE)); 286 } 287 288 private List<String> _fieldNamesFromContentTypes(Collection<ContentType> contentTypes) 289 { 290 List<String> filedNames = new ArrayList<>(); 291 292 for (ContentType contentType : contentTypes) 293 { 294 List<String> fieldNamesFromContentType = _fieldNamesFromModelItemContainer(contentType, StringUtils.EMPTY); 295 filedNames.addAll(fieldNamesFromContentType); 296 } 297 298 return filedNames; 299 } 300 301 private List<String> _fieldNamesFromModelItemContainer(ModelItemContainer modelItemContainer, String prefix) 302 { 303 List<String> fieldNames = new ArrayList<>(); 304 305 for (ModelItem modelItem : modelItemContainer.getModelItems()) 306 { 307 String fieldName = prefix + modelItem.getName(); 308 if (modelItem instanceof CompositeDefinition compositeDefinition) 309 { 310 fieldNames.addAll(_fieldNamesFromModelItemContainer(compositeDefinition, fieldName + ModelItem.ITEM_PATH_SEPARATOR)); 311 } 312 else 313 { 314 fieldNames.add(fieldName); 315 } 316 } 317 318 return fieldNames; 319 } 320 321 List<String> getWildcardSystemColumnPaths(Set<ContentType> commonContentTypes, String path, boolean allowComposite) 322 { 323 if (!isWildcardColumn(path)) 324 { 325 throw new IllegalArgumentException("The given path '" + path + "' does not have the correct syntax."); 326 } 327 328 if ("*".equals(path)) 329 { 330 return _systemPropEP.getDisplayProperties(); 331 } 332 else if (commonContentTypes.isEmpty()) 333 { 334 throw new IllegalArgumentException("The given path '" + path + "' is invalid with an empty common content type (the path cannot be followed)."); 335 } 336 else 337 { 338 String parentPath = StringUtils.substringBeforeLast(path, "/*"); 339 340 if (!ModelHelper.hasModelItem(parentPath, commonContentTypes)) 341 { 342 throw new IllegalArgumentException("Invalid column definition '" + path + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'"); 343 } 344 345 ModelItem lastModelItem = ModelHelper.getModelItem(parentPath, commonContentTypes); 346 if (lastModelItem instanceof ContentAttributeDefinition) 347 { 348 String basePath = parentPath + "/"; 349 return _systemPropEP.getDisplayProperties() 350 .stream() 351 .map(prop -> basePath + prop) 352 .collect(Collectors.toList()); 353 } 354 else if (lastModelItem instanceof ModelItemGroup && allowComposite) 355 { 356 return List.of(); 357 } 358 else 359 { 360 throw new IllegalArgumentException("Invalid column definition '" + path + "' in content types '" + _getJoinedContentTypeIds(commonContentTypes) + "'"); 361 } 362 } 363 } 364 365 private String _getJoinedContentTypeIds(Set<ContentType> contentTypes) 366 { 367 Set<String> commonContentTypeIds = contentTypes.stream() 368 .map(ContentType::getId) 369 .collect(Collectors.toSet()); 370 return StringUtils.join(commonContentTypeIds, ", "); 371 } 372 373 /** 374 * Creates a {@link View} from the given columns 375 * @param contentTypeIds the reference content type identifiers. 376 * @param columns the columns 377 * @param mergeContainers set to <code>true</code> to avoid to create new containers when they are already present in the given view item accessor 378 * @return the created view 379 */ 380 public View createViewFromColumns(Set<String> contentTypeIds, Collection<Column> columns, boolean mergeContainers) 381 { 382 View resultItems = new View(); 383 for (Column column : columns) 384 { 385 String columnId = column.getId(); 386 387 Collection<ContentType> contentTypes = contentTypeIds.stream() 388 .map(_cTypeEP::getExtension) 389 .toList(); 390 391 String[] pathSegments = StringUtils.split(columnId, ModelItem.ITEM_PATH_SEPARATOR); 392 393 ViewItemAccessor parent = resultItems; 394 Collection<? extends ModelItemAccessor> realtiveModelItemAccessors = contentTypes; 395 if (pathSegments.length > 1) 396 { 397 // Create or get parent 398 String parentPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 0, pathSegments.length - 1); 399 ModelViewItem parentViewItem = org.ametys.runtime.model.ViewHelper.addViewItem(parentPath, resultItems, false, mergeContainers, contentTypes.toArray(new ContentType[contentTypes.size()])); 400 401 if (parentViewItem instanceof ViewItemAccessor parentViewItemAccessor) 402 { 403 parent = parentViewItemAccessor; 404 realtiveModelItemAccessors = List.of((ModelItemAccessor) parentViewItem.getDefinition()); 405 } 406 else 407 { 408 throw new IllegalArgumentException("Unable to create a column with id '" + columnId + "'. The item at path '" + parentPath + "' is not an accessor."); 409 } 410 } 411 412 ViewItem viewItem = _createViewItem(pathSegments[pathSegments.length - 1], column.getLabel(), realtiveModelItemAccessors); 413 parent.addViewItem(viewItem); 414 } 415 return resultItems; 416 } 417 418 /** 419 * Creates the {@link ViewItem} with the given name. Retrieves the created view item 420 * @param viewItemName the view item name 421 * @param columnLabel the label of the column, to put to the view item 422 * @param modelItemAccessors the accessors containing the model item with the given name 423 * @return the created view item 424 */ 425 private ViewItem _createViewItem(String viewItemName, Optional<String> columnLabel, Collection<? extends ModelItemAccessor> modelItemAccessors) 426 { 427 ModelItem modelItem = ModelHelper.hasModelItem(viewItemName, modelItemAccessors) 428 ? ModelHelper.getModelItem(viewItemName, modelItemAccessors) 429 : Content.ATTRIBUTE_TITLE.equals(viewItemName) 430 ? _cTypeHelper.getTitleAttributeDefinition() 431 : _systemPropEP.hasExtension(viewItemName) 432 ? _systemPropEP.getExtension(viewItemName) 433 : null; 434 435 if (modelItem == null) 436 { 437 throw new IllegalArgumentException("Unable to create a column with name '" + viewItemName + "'. There is no corresponding model item in model."); 438 } 439 440 // The viewItem is the leaf, create an UI column and add label if needed 441 ModelViewItem viewItem = SearchUIColumnHelper.createModelItemColumn(modelItem); 442 columnLabel.map(I18nizableText::new) 443 .ifPresent(viewItem::setLabel); 444 445 return viewItem; 446 } 447 448 static final class ColumnTransformer 449 { 450 private Set<ContentType> _contentTypeIds; 451 private ColumnHelper _columnHelper; 452 453 ColumnTransformer(Set<ContentType> contentTypeIds, ColumnHelper columnHelper) 454 { 455 _contentTypeIds = contentTypeIds; 456 _columnHelper = columnHelper; 457 } 458 459 Stream<Column> transform(Column column) 460 { 461 String colId = column.getId(); 462 if (_columnHelper.isWildcardColumn(colId)) 463 { 464 return Stream.concat(_attributeCols(colId), _systemCols(colId)); 465 } 466 else if (_columnHelper.isViewReference(colId)) 467 { 468 return _viewRefs(colId); 469 } 470 else 471 { 472 return Stream.of(column); 473 } 474 } 475 476 private Stream<Column> _attributeCols(String colPath) 477 { 478 return _columnHelper.getWildcardAttributeColumnPaths(_contentTypeIds, colPath) 479 .stream() 480 .map(colId -> new Column(colId, null)); 481 } 482 483 private Stream<Column> _systemCols(String colPath) 484 { 485 return _columnHelper.getWildcardSystemColumnPaths(_contentTypeIds, colPath, true) 486 .stream() 487 .map(colId -> new Column(colId, null)); 488 } 489 490 private Stream<Column> _viewRefs(String colPath) 491 { 492 return _columnHelper.getViewReferenceColumnPaths(_contentTypeIds, colPath) 493 .stream() 494 .map(colId -> new Column(colId, null)); 495 } 496 } 497 498 /** 499 * A column and its (optional) label 500 */ 501 public static final class Column 502 { 503 private String _id; 504 private Optional<String> _label; 505 506 /** 507 * Creates a {@link Column} object, wrapping a column id and its (optional) label. 508 * <br>If the provided label is <code>null</code>, then a default label will be applied to the column. 509 * @param columnId The id of the column 510 * @param columnLabel The label of the column. Cannot contain the comma character (<b>,</b>). Can be null 511 */ 512 public Column(String columnId, String columnLabel) 513 { 514 _id = columnId; 515 _label = Optional.ofNullable(columnLabel); 516 if (_label.filter(l -> l.contains(",")).isPresent()) 517 { 518 throw new IllegalArgumentException("The label cannot contain a comma."); 519 } 520 } 521 522 /** 523 * Gets the id of the column 524 * @return the id of the column 525 */ 526 public String getId() 527 { 528 return _id; 529 } 530 531 /** 532 * Gets the label of the column 533 * @return the label of the column 534 */ 535 public Optional<String> getLabel() 536 { 537 return _label; 538 } 539 540 @Override 541 public String toString() 542 { 543 return "Column<" + _id + ", \"" + _label.orElse("<NO LABEL>") + "\">"; 544 } 545 546 @Override 547 public int hashCode() 548 { 549 final int prime = 31; 550 int result = 1; 551 result = prime * result + ((_id == null) ? 0 : _id.hashCode()); 552 return result; 553 } 554 555 // only based on the column id 556 @Override 557 public boolean equals(Object obj) 558 { 559 if (this == obj) 560 { 561 return true; 562 } 563 if (obj == null) 564 { 565 return false; 566 } 567 if (getClass() != obj.getClass()) 568 { 569 return false; 570 } 571 Column other = (Column) obj; 572 if (_id == null) 573 { 574 if (other._id != null) 575 { 576 return false; 577 } 578 } 579 else if (!_id.equals(other._id)) 580 { 581 return false; 582 } 583 return true; 584 } 585 } 586}