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