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.data; 017 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.Collection; 021import java.util.Collections; 022import java.util.HashMap; 023import java.util.HashSet; 024import java.util.List; 025import java.util.Map; 026import java.util.Optional; 027import java.util.Set; 028import java.util.function.Function; 029import java.util.stream.Collectors; 030import java.util.stream.IntStream; 031import java.util.stream.Stream; 032 033import org.apache.avalon.framework.component.Component; 034import org.apache.avalon.framework.service.ServiceException; 035import org.apache.avalon.framework.service.ServiceManager; 036import org.apache.avalon.framework.service.Serviceable; 037import org.apache.commons.collections4.SetUtils; 038import org.apache.commons.lang3.StringUtils; 039import org.apache.commons.lang3.tuple.ImmutablePair; 040import org.apache.commons.lang3.tuple.Pair; 041 042import org.ametys.cms.contenttype.ContentAttributeDefinition; 043import org.ametys.cms.contenttype.ContentType; 044import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 045import org.ametys.cms.data.type.ModelItemTypeConstants; 046import org.ametys.cms.repository.Content; 047import org.ametys.cms.repository.ModifiableContent; 048import org.ametys.plugins.repository.AmetysObjectResolver; 049import org.ametys.plugins.repository.data.external.ExternalizableDataProvider.ExternalizableDataStatus; 050import org.ametys.plugins.repository.data.holder.DataHolder; 051import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder; 052import org.ametys.plugins.repository.data.holder.ModifiableModelAwareDataHolder; 053import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeater; 054import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeaterEntry; 055import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareComposite; 056import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeater; 057import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeaterEntry; 058import org.ametys.plugins.repository.data.holder.impl.DataHolderHelper; 059import org.ametys.plugins.repository.data.holder.values.SynchronizableRepeater; 060import org.ametys.plugins.repository.data.holder.values.SynchronizableValue; 061import org.ametys.plugins.repository.data.holder.values.SynchronizationContext; 062import org.ametys.plugins.repository.data.holder.values.UntouchedValue; 063import org.ametys.plugins.repository.data.holder.values.ValueContext; 064import org.ametys.plugins.repository.model.RepeaterDefinition; 065import org.ametys.plugins.repository.model.ViewHelper; 066import org.ametys.runtime.model.ModelItem; 067import org.ametys.runtime.model.ModelViewItemGroup; 068import org.ametys.runtime.model.View; 069import org.ametys.runtime.model.ViewItemContainer; 070import org.ametys.runtime.model.exception.BadItemTypeException; 071 072/** 073 * Helper for data of type 'content' 074 */ 075public class ContentDataHelper implements Serviceable, Component 076{ 077 /** Avalon role */ 078 public static final String ROLE = ContentDataHelper.class.getName(); 079 080 private AmetysObjectResolver _resolver; 081 private ContentTypeExtensionPoint _cTypeExtensionPoint; 082 083 public void service(ServiceManager manager) throws ServiceException 084 { 085 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 086 _cTypeExtensionPoint = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 087 } 088 089 /** 090 * Retrieves the content identifier of a content data 091 * @param dataHolder data holder that contains the content data 092 * @param dataPath path to the content data 093 * @param defaultValue The default value to return 094 * @return the content identifier 095 * @throws BadItemTypeException if the data at the given path is not a content data 096 */ 097 public static String getContentIdFromContentData(ModelAwareDataHolder dataHolder, String dataPath, String defaultValue) throws BadItemTypeException 098 { 099 ContentValue value = dataHolder.getValue(dataPath); 100 return Optional.ofNullable(value) 101 .map(ContentValue::getContentId) 102 .orElse(defaultValue); 103 } 104 105 /** 106 * Retrieves the content identifier of a content data 107 * @param dataHolder data holder that contains the content data 108 * @param dataPath path to the content data 109 * @return the content identifier, empty string if it is invalid 110 * @throws BadItemTypeException if the data at the given path is not a content data 111 */ 112 public static String getContentIdFromContentData(ModelAwareDataHolder dataHolder, String dataPath) throws BadItemTypeException 113 { 114 return getContentIdFromContentData(dataHolder, dataPath, StringUtils.EMPTY); 115 } 116 117 /** 118 * Retrieves the content identifiers in an array from a multiple content data 119 * @param dataHolder data holder that contains the multiple content data 120 * @param dataPath path to the multiple content data 121 * @return an array containing the content identifiers 122 * @throws BadItemTypeException if the data at the given path is not a content data 123 */ 124 public static boolean isMultipleContentDataEmpty(ModelAwareDataHolder dataHolder, String dataPath) throws BadItemTypeException 125 { 126 return isMultipleContentDataEmpty(dataHolder, dataPath, ValueContext.newInstance()); 127 } 128 129 /** 130 * Retrieves the content identifiers in an array from a multiple content data 131 * @param dataHolder data holder that contains the multiple content data 132 * @param dataPath path to the multiple content data 133 * @param context the context of the value to retrieve 134 * @return an array containing the content identifiers 135 * @throws BadItemTypeException if the data at the given path is not a content data 136 */ 137 public static boolean isMultipleContentDataEmpty(ModelAwareDataHolder dataHolder, String dataPath, ValueContext context) throws BadItemTypeException 138 { 139 return getContentIdsStreamFromMultipleContentData(dataHolder, dataPath, context).count() <= 0; 140 } 141 142 /** 143 * Retrieves the content identifiers in a {@link List} from a multiple content data 144 * @param dataHolder data holder that contains the multiple content data 145 * @param dataPath path to the multiple content data 146 * @return a {@link List} containing the content identifiers 147 * @throws BadItemTypeException if the data at the given path is not a content data 148 */ 149 public static List<String> getContentIdsListFromMultipleContentData(ModelAwareDataHolder dataHolder, String dataPath) throws BadItemTypeException 150 { 151 return getContentIdsListFromMultipleContentData(dataHolder, dataPath, ValueContext.newInstance()); 152 } 153 154 /** 155 * Retrieves the content identifiers in a {@link List} from a multiple content data 156 * @param dataHolder data holder that contains the multiple content data 157 * @param dataPath path to the multiple content data 158 * @param context the context of the value to retrieve 159 * @return a {@link List} containing the content identifiers 160 * @throws BadItemTypeException if the data at the given path is not a content data 161 */ 162 public static List<String> getContentIdsListFromMultipleContentData(ModelAwareDataHolder dataHolder, String dataPath, ValueContext context) throws BadItemTypeException 163 { 164 return getContentIdsStreamFromMultipleContentData(dataHolder, dataPath, context).collect(Collectors.toList()); 165 } 166 167 /** 168 * Retrieves the content identifiers in an array from a multiple content data 169 * @param dataHolder data holder that contains the multiple content data 170 * @param dataPath path to the multiple content data 171 * @return an array containing the content identifiers 172 * @throws BadItemTypeException if the data at the given path is not a content data 173 */ 174 public static String[] getContentIdsArrayFromMultipleContentData(ModelAwareDataHolder dataHolder, String dataPath) throws BadItemTypeException 175 { 176 return getContentIdsArrayFromMultipleContentData(dataHolder, dataPath, ValueContext.newInstance()); 177 } 178 179 /** 180 * Retrieves the content identifiers in an array from a multiple content data 181 * @param dataHolder data holder that contains the multiple content data 182 * @param dataPath path to the multiple content data 183 * @param context the context of the value to retrieve 184 * @return an array containing the content identifiers 185 * @throws BadItemTypeException if the data at the given path is not a content data 186 */ 187 public static String[] getContentIdsArrayFromMultipleContentData(ModelAwareDataHolder dataHolder, String dataPath, ValueContext context) throws BadItemTypeException 188 { 189 return getContentIdsStreamFromMultipleContentData(dataHolder, dataPath, context).toArray(String[]::new); 190 } 191 192 /** 193 * Retrieves a {@link Stream} of the content identifiers from a multiple content data 194 * @param dataHolder data holder that contains the multiple content data 195 * @param dataPath path to the multiple content data 196 * @return a {@link Stream} of the content identifiers 197 * @throws BadItemTypeException if the data at the given path is not a content data 198 */ 199 public static Stream<String> getContentIdsStreamFromMultipleContentData(ModelAwareDataHolder dataHolder, String dataPath) throws BadItemTypeException 200 { 201 return getContentIdsStreamFromMultipleContentData(dataHolder, dataPath, ValueContext.newInstance()); 202 } 203 204 /** 205 * Retrieves a {@link Stream} of the content identifiers from a multiple content data 206 * @param dataHolder data holder that contains the multiple content data 207 * @param dataPath path to the multiple content data 208 * @param context the context of the value to retrieve 209 * @return a {@link Stream} of the content identifiers 210 * @throws BadItemTypeException if the data at the given path is not a content data 211 */ 212 public static Stream<String> getContentIdsStreamFromMultipleContentData(ModelAwareDataHolder dataHolder, String dataPath, ValueContext context) throws BadItemTypeException 213 { 214 ContentValue[] value = DataHolderHelper.getValue(dataHolder, dataPath, context); 215 return Optional.ofNullable(value) 216 .map(v -> Arrays.stream(v)) 217 .orElse(Stream.empty()) 218 .map(ContentValue::getContentId); 219 } 220 221 /** 222 * Prepares a write operation in the given {@link ModelAwareDataHolder} by traversing a {@link View} with the values to be written.<br> 223 * The goal is to find content attributes and to extract added and removed values. 224 * @param viewItemContainer the view to walk through 225 * @param content the source {@link ModifiableContent} 226 * @param values the new values 227 * @param context the synchronization context 228 * @return the contents to-be-added and to-be-removed 229 */ 230 public Collection<ReferencedContents> collectReferencedContents(ViewItemContainer viewItemContainer, ModifiableContent content, Map<String, Object> values, SynchronizationContext context) 231 { 232 // content ids, grouped by invert attribute paths, instead of definition paths, so that if several attributes share the same invert attribute, they are handled together 233 // data structure is <definition> mapped <previous contents mapped with their data paths, new contents mapped with their data paths> 234 Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references = new HashMap<>(); 235 236 _collectReferencedContents(content, viewItemContainer, Optional.of(content), StringUtils.EMPTY, Optional.of(StringUtils.EMPTY), Optional.of(values), false, context, references); 237 238 Collection<ReferencedContents> contents = new ArrayList<>(); 239 for (ContentAttributeDefinition definition : references.keySet()) 240 { 241 Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>> allValues = references.get(definition); 242 Map<ContentValue, List<String>> previousValues = allValues.getLeft(); 243 Map<ContentValue, List<String>> newValues = allValues.getRight(); 244 245 Map<ContentValue, List<String>> addedValues = new HashMap<>(); 246 Map<ContentValue, List<String>> removedValues = new HashMap<>(); 247 248 newValues.forEach((value, list) -> { 249 if (!previousValues.containsKey(value)) 250 { 251 addedValues.put(value, list); 252 } 253 }); 254 255 previousValues.forEach((value, list) -> { 256 if (!newValues.containsKey(value)) 257 { 258 removedValues.put(value, list); 259 } 260 }); 261 262 String invert = definition.getInvertRelationPath(); 263 ContentType invertType = _cTypeExtensionPoint.getExtension(definition.getContentTypeId()); 264 ContentAttributeDefinition invertDefinition = (ContentAttributeDefinition) invertType.getModelItem(invert); 265 266 Map<ContentValue, ContentValue> thirdPartyContents = new HashMap<>(); 267 if (!DataHolderHelper.isMultiple(invertDefinition)) 268 { 269 List<ModifiableContent> refContents = addedValues.keySet().stream().map(v -> v.getContentIfExists()).flatMap(Optional::stream).collect(Collectors.toList()); 270 271 for (ModifiableContent refContent : refContents) 272 { 273 ContentValue previousValue = refContent.getValue(invert); 274 if (previousValue != null) 275 { 276 String previousContentId = previousValue.getContentId(); 277 if (!content.getId().equals(previousContentId)) 278 { 279 // the single-valued content attribute is about to change, we should store the 3rd party content involved in the invert relation 280 thirdPartyContents.put(new ContentValue(refContent), previousValue); 281 } 282 } 283 } 284 } 285 286 contents.add(new ReferencedContents(definition, addedValues, removedValues, thirdPartyContents)); 287 } 288 289 return contents; 290 } 291 292 @SuppressWarnings("unchecked") 293 private void _collectReferencedContents(Content content, 294 ViewItemContainer viewItemContainer, 295 Optional<ModelAwareDataHolder> currentDataHolder, 296 String dataPathPrefix, 297 Optional<String> oldDataPathPrefix, 298 Optional<Map<String, Object>> values, 299 boolean insideKeptDataHolder, 300 SynchronizationContext context, 301 Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references) 302 { 303 ViewHelper.visitView(viewItemContainer, 304 (element, definition) -> { 305 // simple element 306 String name = definition.getName(); 307 308 if (definition instanceof ContentAttributeDefinition) 309 { 310 ContentAttributeDefinition contentDefinition = (ContentAttributeDefinition) definition; 311 String invert = contentDefinition.getInvertRelationPath(); 312 if (invert != null) 313 { 314 _collectInvertRelation(content, currentDataHolder, dataPathPrefix, oldDataPathPrefix, values, insideKeptDataHolder, name, contentDefinition, context, references); 315 } 316 } 317 }, 318 (group, definition) -> { 319 // composite 320 String name = definition.getName(); 321 String updatedPrefix = dataPathPrefix + name + ModelItem.ITEM_PATH_SEPARATOR; 322 Optional<String> updatedOldPrefix = oldDataPathPrefix.map(p -> p + name + ModelItem.ITEM_PATH_SEPARATOR); 323 _collectReferencedContents(content, group, currentDataHolder.map(v -> v.getComposite(name)), updatedPrefix, updatedOldPrefix, values.map(v -> (Map<String, Object>) v.get(name)), insideKeptDataHolder, context, references); 324 }, 325 (group, definition) -> { 326 // repeater 327 String name = definition.getName(); 328 _collectRepeater(content, group, currentDataHolder, dataPathPrefix, oldDataPathPrefix, values, insideKeptDataHolder, context, name, references); 329 }, 330 group -> _collectReferencedContents(content, group, currentDataHolder, dataPathPrefix, oldDataPathPrefix, values, insideKeptDataHolder, context, references)); 331 } 332 333 private void _collectInvertRelation(Content content, 334 Optional<ModelAwareDataHolder> currentDataHolder, 335 String dataPathPrefix, 336 Optional<String> oldDataPathPrefix, 337 Optional<Map<String, Object>> values, 338 boolean insideKeptDataHolder, 339 String name, 340 ContentAttributeDefinition definition, 341 SynchronizationContext context, 342 Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references) 343 { 344 String dataPath = dataPathPrefix + name; 345 346 Set<ContentValue> previousContents = currentDataHolder 347 .map(dh -> dh.getValue(name)) 348 .map(this::_getContentRefs) 349 .orElseGet(Collections::emptySet); 350 Set<ContentValue> newContents; 351 352 if (insideKeptDataHolder) 353 { 354 newContents = previousContents; 355 } 356 else 357 { 358 Optional<Object> rawValue = values.map(v -> v.get(name)); 359 360 Optional<String> oldDataPath = oldDataPathPrefix.map(p -> p + name); 361 rawValue = rawValue.map(v -> DataHolderHelper.getValueFromSynchronizableValue(v, content, definition, oldDataPath, context)); 362 363 // If the current value is an untouched value, don't edit the invert relation 364 if (rawValue.filter(UntouchedValue.class::isInstance).isPresent()) 365 { 366 return; 367 } 368 369 newContents = rawValue.map(this::_getContentRefs) 370 .orElseGet(Collections::emptySet); 371 372 SynchronizableValue.Mode mode = values.map(v -> v.get(name)) 373 .filter(SynchronizableValue.class::isInstance) 374 .map(SynchronizableValue.class::cast) 375 .map(v -> v.getMode()) 376 .orElse(SynchronizableValue.Mode.REPLACE); 377 378 if (mode == SynchronizableValue.Mode.REMOVE) 379 { 380 newContents = SetUtils.difference(previousContents, newContents); 381 } 382 else if (mode == SynchronizableValue.Mode.APPEND) 383 { 384 newContents = SetUtils.union(previousContents, newContents); 385 } 386 } 387 388 _addReferences(references, definition, dataPath, previousContents, newContents); 389 } 390 391 private Set<ContentValue> _getContentRefs(Object value) 392 { 393 if (value instanceof ContentValue) 394 { 395 return Set.of((ContentValue) value); 396 } 397 else if (value instanceof ContentValue[]) 398 { 399 return Set.copyOf(Arrays.asList((ContentValue[]) value)); 400 } 401 else if (value instanceof String) 402 { 403 return Set.of(new ContentValue(_resolver, (String) value)); 404 } 405 else if (value instanceof String[]) 406 { 407 return Arrays.stream((String[]) value).map(id -> new ContentValue(_resolver, id)).collect(Collectors.toSet()); 408 } 409 else if (value instanceof ModifiableContent) 410 { 411 return Set.of(new ContentValue((ModifiableContent) value)); 412 } 413 else if (value instanceof ModifiableContent[]) 414 { 415 return Arrays.stream((ModifiableContent[]) value).map(ContentValue::new).collect(Collectors.toSet()); 416 } 417 418 return Collections.emptySet(); 419 } 420 421 private void _addReferences(Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references, ContentAttributeDefinition definition, String dataPath, Set<ContentValue> previousContents, Set<ContentValue> newContents) 422 { 423 Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>> values = references.get(definition); 424 Map<ContentValue, List<String>> previousValues = values == null ? new HashMap<>() : values.getLeft(); 425 Map<ContentValue, List<String>> newValues = values == null ? new HashMap<>() : values.getRight(); 426 427 previousContents.forEach(c -> previousValues.computeIfAbsent(c, __ -> new ArrayList<>()).add(dataPath)); 428 newContents.forEach(c -> newValues.computeIfAbsent(c, __ -> new ArrayList<>()).add(dataPath)); 429 430 references.put(definition, Pair.of(previousValues, newValues)); 431 } 432 433 @SuppressWarnings("unchecked") 434 private void _collectRepeater(Content content, 435 ModelViewItemGroup group, 436 Optional<ModelAwareDataHolder> currentDataHolder, 437 String dataPathPrefix, 438 Optional<String> oldDataPathPrefix, 439 Optional<Map<String, Object>> values, 440 boolean insideKeptDataHolder, 441 SynchronizationContext context, 442 String name, 443 Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references) 444 { 445 Object value = values.map(v -> v.get(name)).orElse(null); 446 List<Map<String, Object>> newEntries = null; 447 Map<Integer, Integer> mapping = null; 448 SynchronizableRepeater.Mode mode = SynchronizableRepeater.Mode.REPLACE_ALL; 449 if (value instanceof List) 450 { 451 newEntries = (List<Map<String, Object>>) value; 452 mapping = IntStream.rangeClosed(1, newEntries.size()).boxed().collect(Collectors.toMap(Function.identity(), Function.identity())); 453 } 454 else if (value instanceof SynchronizableRepeater) 455 { 456 newEntries = ((SynchronizableRepeater) value).getEntries(); 457 mapping = ((SynchronizableRepeater) value).getPositionsMapping(); 458 mode = ((SynchronizableRepeater) value).getMode(); 459 } 460 461 // first collect data for actually existing entries 462 Set<Integer> alreadyHandledValues = new HashSet<>(); 463 ModelAwareRepeater repeater = currentDataHolder.map(v -> v.getRepeater(name)).orElse(null); 464 if (repeater != null) 465 { 466 for (ModelAwareRepeaterEntry entry : repeater.getEntries()) 467 { 468 int position = entry.getPosition(); 469 470 Optional<Map<String, Object>> newValues = Optional.empty(); 471 String newPositionSuffix = ""; 472 boolean newKeptValue = insideKeptDataHolder; 473 474 if (!insideKeptDataHolder) 475 { 476 if (mode == SynchronizableRepeater.Mode.REPLACE_ALL) 477 { 478 Integer newPosition = mapping != null ? mapping.get(position) : null; 479 480 if (newPosition != null) 481 { 482 // not removed entry 483 newValues = newEntries != null ? Optional.ofNullable(newEntries.get(newPosition - 1)) : Optional.empty(); 484 newPositionSuffix = "[" + newPosition + "]"; 485 alreadyHandledValues.add(newPosition); 486 } 487 } 488 else if (mode == SynchronizableRepeater.Mode.REPLACE) 489 { 490 List<Integer> replacePositions = ((SynchronizableRepeater) value).getReplacePositions(); 491 int index = replacePositions.indexOf(position); 492 493 if (index >= 0) 494 { 495 assert newEntries != null; 496 newValues = Optional.ofNullable(newEntries.get(index)); 497 newPositionSuffix = "[" + position + "]"; 498 alreadyHandledValues.add(position); 499 } 500 else 501 { 502 // entry kept as is 503 newKeptValue = true; 504 } 505 } 506 else 507 { 508 Set<Integer> removedEntries = ((SynchronizableRepeater) value).getRemovedEntries(); 509 newKeptValue = !removedEntries.contains(position); 510 } 511 } 512 513 String updatePrefix = dataPathPrefix + name + newPositionSuffix + ModelItem.ITEM_PATH_SEPARATOR; 514 Optional<String> updatedOldPrefix = oldDataPathPrefix.map(p -> dataPathPrefix + name + "[" + position + "]" + ModelItem.ITEM_PATH_SEPARATOR); 515 _collectReferencedContents(content, group, Optional.of(entry), updatePrefix, updatedOldPrefix, newValues, newKeptValue, context, references); 516 } 517 } 518 519 // then collect data for newly created entries 520 if (newEntries != null) 521 { 522 for (int i = 1; i <= newEntries.size(); i++) 523 { 524 if (!alreadyHandledValues.contains(i)) 525 { 526 String updatedPrefix = dataPathPrefix + name + "[" + i + "]" + ModelItem.ITEM_PATH_SEPARATOR; 527 _collectReferencedContents(content, group, Optional.empty(), updatedPrefix, Optional.empty(), Optional.of(newEntries.get(i - 1)), insideKeptDataHolder, context, references); 528 } 529 } 530 } 531 } 532 533 /** 534 * Find any broken reference in data holder's content attributes. 535 * A reference is considered as broken if the referenced content does not exist 536 * @param dataHolder the {@link ModelAwareDataHolder} to inspect. 537 * @param fix if <code>true</code>, and the dataHolder is modifiable, broken references will be removed 538 * Warning: if modifications are made on the given data holder, the modifications won't be saved and the workflow won't change 539 * @return all broken references, by attribute path 540 */ 541 public static Map<String, List<String>> checkBrokenReferences(ModelAwareDataHolder dataHolder, boolean fix) 542 { 543 Map<String, List<String>> result = new HashMap<>(); 544 Map<String, Object> contentAttributes = DataHolderHelper.findEditableItemsByType(dataHolder, ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID); 545 for (Map.Entry<String, Object> entry : contentAttributes.entrySet()) 546 { 547 String dataPath = entry.getKey(); 548 Object value = entry.getValue(); 549 if (value instanceof ContentValue) 550 { 551 ContentValue contentValue = (ContentValue) value; 552 if (contentValue.getContentIfExists().isEmpty()) 553 { 554 result.computeIfAbsent(dataPath, __ -> new ArrayList<>()).add(contentValue.getContentId()); 555 if (fix && dataHolder instanceof ModifiableModelAwareDataHolder) 556 { 557 ((ModifiableModelAwareDataHolder) dataHolder).setValue(dataPath, null); 558 } 559 } 560 } 561 else if (value instanceof ContentValue[]) 562 { 563 List<ContentValue> newValues = new ArrayList<>(); 564 for (ContentValue contentValue : (ContentValue[]) value) 565 { 566 if (contentValue.getContentIfExists().isEmpty()) 567 { 568 result.computeIfAbsent(dataPath, __ -> new ArrayList<>()).add(contentValue.getContentId()); 569 } 570 else if (fix) 571 { 572 newValues.add(contentValue); 573 } 574 } 575 if (fix && dataHolder instanceof ModifiableModelAwareDataHolder) 576 { 577 ((ModifiableModelAwareDataHolder) dataHolder).setValue(dataPath, newValues.toArray(ContentValue[]::new)); 578 } 579 } 580 } 581 return result; 582 } 583 584 /** 585 * Find any broken invert relation in a content attributes. 586 * The invert relation is considered as broken if the referenced content does not reference the first one by the attribute with the invert relation path 587 * This check does not take nonexistent referenced contents into account. First call the {@link ContentDataHelper#checkBrokenReferences(ModelAwareDataHolder, boolean)} method 588 * @param content the content to inspect. 589 * @param fix if <code>true</code>, and the referenced contents are modifiable, broken invert relations will be fixed by adding the relation on the referenced content 590 * if the referenced content is referencing another content and the attribute is single (and not in a repeater), the third content will be modified too, to remove the relation to the second content. 591 * Warning: if modifications are made on some contents, the modifications won't be saved and the workflows won't change 592 * @return All broken invert relations in a collection of {@link ReferencedContents}. 593 * Call {@link ReferencedContents#getAddedContents()} to get all contents referenced by the given content that does not reference it in return 594 * Call {@link ReferencedContents#getThirdPartyContents()} to get contents referenced by content in added contents that reference it in return 595 */ 596 public static Collection<ReferencedContents> checkBrokenInvertRelations(Content content, boolean fix) 597 { 598 // Search for all content attribute's values 599 Map<String, Object> contentAttributes = DataHolderHelper.findEditableItemsByType(content, ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID); 600 601 // Collect broken invert relations 602 Collection<ReferencedContents> brokenInvertRelations = new ArrayList<>(); 603 for (Map.Entry<String, Object> entry : contentAttributes.entrySet()) 604 { 605 String dataPath = entry.getKey(); 606 Object value = entry.getValue(); 607 ContentAttributeDefinition definition = (ContentAttributeDefinition) content.getDefinition(dataPath); 608 609 // Continue check only on content attributes with an invert relation path 610 if (StringUtils.isNotBlank(definition.getInvertRelationPath())) 611 { 612 Map<ContentValue, List<String>> contentsToAdd = new HashMap<>(); 613 Map<ContentValue, ContentValue> thirdPartyContents = new HashMap<>(); 614 615 if (value instanceof ContentValue) 616 { 617 Pair<Boolean, Optional<ContentValue>> isBroken = _isInvertRelationBroken(content, (ContentValue) value, definition); 618 if (isBroken.getLeft()) 619 { 620 contentsToAdd.put((ContentValue) value, List.of(dataPath)); 621 isBroken.getRight() 622 .ifPresent(v -> thirdPartyContents.put((ContentValue) value, v)); 623 } 624 } 625 else if (value instanceof ContentValue[]) 626 { 627 for (ContentValue contentValue : (ContentValue[]) value) 628 { 629 Pair<Boolean, Optional<ContentValue>> isBroken = _isInvertRelationBroken(content, contentValue, definition); 630 if (isBroken.getLeft()) 631 { 632 contentsToAdd.computeIfAbsent(contentValue, __ -> new ArrayList<>()).add(dataPath); 633 isBroken.getRight() 634 .ifPresent(v -> thirdPartyContents.put(contentValue, v)); 635 } 636 } 637 } 638 639 // Add a ReferencedContents object if there is at least one broken relation 640 // No need to check the third part content, this map can't have entries if the first one is empty 641 if (!contentsToAdd.isEmpty()) 642 { 643 ReferencedContents brokenInvertRelation = new ReferencedContents(definition, contentsToAdd, Map.of(), thirdPartyContents); 644 brokenInvertRelations.add(brokenInvertRelation); 645 } 646 } 647 } 648 649 if (fix) 650 { 651 Map<String, ModifiableContent> modifiedContents = new HashMap<>(); 652 653 for (ReferencedContents brokenInvertRelation : brokenInvertRelations) 654 { 655 // Add relation on contents referenced by the given one 656 String invertRelationPath = brokenInvertRelation.getDefinition().getInvertRelationPath(); 657 modifiedContents.putAll(manageInvertRelations(invertRelationPath, brokenInvertRelation.getAddedContents(), content.getId(), ContentDataHelper::addInvertRelation, modifiedContents, Map.of())); 658 659 // Remove relations on third party contents 660 Map<ContentValue, Collection<String>> thirdPartyContents = new HashMap<>(); 661 for (Map.Entry<ContentValue, ContentValue> thirdPartyContent : brokenInvertRelation.getThirdPartyContents().entrySet()) 662 { 663 thirdPartyContents.computeIfAbsent(thirdPartyContent.getValue(), __ -> new ArrayList<>()).add(thirdPartyContent.getKey().getContentId()); 664 } 665 modifiedContents.putAll(manageInvertRelations(brokenInvertRelation.getDefinition().getPath(), thirdPartyContents, ContentDataHelper::removeInvertRelation, modifiedContents, Map.of())); 666 } 667 } 668 669 return brokenInvertRelations; 670 } 671 672 /** 673 * Check if the invert relation between the given contents is broken 674 * @param source the source content of the invert relation 675 * @param destinationValue the destination content of the invert relation 676 * @param definition the definition concerned by the checked invert relation 677 * @return a {@link Pair} containing the result of the check ({@link Boolean} part left) and an optional third part content, if the relation is broken and the destination content references another content in a single attribute that should be modified 678 */ 679 private static Pair<Boolean, Optional<ContentValue>> _isInvertRelationBroken(Content source, ContentValue destinationValue, ContentAttributeDefinition definition) 680 { 681 Boolean isBroken = Boolean.FALSE; 682 Optional<ContentValue> thirdPartContent = Optional.empty(); 683 684 // Get the target content - ignore nonexistent contents, the checkBrokenReferences dealt with it 685 Optional<? extends Content> optDestination = destinationValue.getContentIfExists(); 686 if (optDestination.isPresent()) 687 { 688 // Get the value of the target content's attribute 689 Content destination = optDestination.get(); 690 Object value = destination.getValue(definition.getInvertRelationPath(), true); 691 if (value == null) 692 { 693 isBroken = true; 694 } 695 else if (value instanceof ContentValue) 696 { 697 // If the value does not correspond to the current data holder 698 if (!((ContentValue) value).getContentId().equals(source.getId())) 699 { 700 isBroken = true; 701 702 // If the value of the destination is in relation to the destination too, this third part content will have to be modified 703 Object thirdPartValue = ((ContentValue) value).getValue(definition.getPath(), true); 704 if (thirdPartValue instanceof ContentValue) 705 { 706 if (((ContentValue) thirdPartValue).getContentId().equals(destination.getId())) 707 { 708 thirdPartContent = Optional.of((ContentValue) value); 709 } 710 } 711 else if (thirdPartValue instanceof ContentValue[]) 712 { 713 for (ContentValue thridPartSingleValue : (ContentValue[]) thirdPartValue) 714 { 715 if (thridPartSingleValue.getContentId().equals(destination.getId())) 716 { 717 thirdPartContent = Optional.of((ContentValue) value); 718 break; 719 } 720 } 721 } 722 } 723 } 724 else if (value instanceof ContentValue[]) 725 { 726 // Search for the source content in the values of the destination 727 boolean foundSource = false; 728 for (ContentValue contentValue : (ContentValue[]) value) 729 { 730 if (contentValue.getContentId().equals(source.getId())) 731 { 732 foundSource = true; 733 break; 734 } 735 } 736 737 isBroken = Boolean.valueOf(!foundSource); 738 } 739 } 740 741 return new ImmutablePair<>(isBroken, thirdPartContent); 742 } 743 744 /** 745 * Manages the invert relations concerned by the given path, through the given {@link InvertRelationManager} 746 * @param invertRelationPath the concerned invert relation path 747 * @param referencedContents a {@link Set} containing the referenced contents to manage (add or remove the relation) 748 * @param referencingContentId the id of the content referencing the given contents 749 * @param invertRelationManager the {@link InvertRelationManager} to use (to add or remove the invert relations) 750 * @param alreadyModifiedContents a {@link Map} of contents (indexed by their identifiers) that have already been modified. 751 * This map will be used to initialize the returned map and to search for contents that have already been resolved (to resolve each content only once, even if there are several modifications on a same content) 752 * @param externalizableDataContext the context {@link Map} that is used to determine if a data is externalizable 753 * @return a {@link Map} containing the contents (indexed by their identifiers) that have been modified by this call and the ones that have already been modified 754 */ 755 public static Map<String, ModifiableContent> manageInvertRelations(String invertRelationPath, Set<ContentValue> referencedContents, String referencingContentId, InvertRelationManager invertRelationManager, Map<String, ModifiableContent> alreadyModifiedContents, Map<String, Object> externalizableDataContext) 756 { 757 Map<ContentValue, Collection<String>> referencedContentsWithReferencing = referencedContents.stream() 758 .collect(Collectors.toMap(value -> value, value -> Set.of(referencingContentId))); 759 760 return manageInvertRelations(invertRelationPath, referencedContentsWithReferencing, invertRelationManager, alreadyModifiedContents, externalizableDataContext); 761 } 762 763 /** 764 * Manages the invert relations concerned by the given path, through the given {@link InvertRelationManager} 765 * @param invertRelationPath the concerned invert relation path 766 * @param referencedContents a {@link Map} containing the referenced contents to manage (add or remove the relation) and the id of the content referencing this content 767 * @param invertRelationManager the {@link InvertRelationManager} to use (to add or remove the invert relations) 768 * @param alreadyModifiedContents a {@link Map} of contents (indexed by their identifiers) that have already been modified. 769 * This map will be used to initialize the returned map and to search for contents that have already been resolved (to resolve each content only once, even if there are several modifications on a same content) 770 * @param externalizableDataContext the context {@link Map} that is used to determine if a data is externalizable 771 * @return a {@link Map} containing the contents (indexed by their identifiers) that have been modified by this call and the ones that have already been modified 772 */ 773 public static Map<String, ModifiableContent> manageInvertRelations(String invertRelationPath, Map<ContentValue, Collection<String>> referencedContents, InvertRelationManager invertRelationManager, Map<String, ModifiableContent> alreadyModifiedContents, Map<String, Object> externalizableDataContext) 774 { 775 Map<String, ModifiableContent> allModifiedContents = new HashMap<>(alreadyModifiedContents); 776 777 for (ContentValue referencedContent : referencedContents.keySet()) 778 { 779 String contentId = referencedContent.getContentId(); 780 Optional<ModifiableContent> optContent = allModifiedContents.containsKey(contentId) 781 ? Optional.of(allModifiedContents.get(contentId)) 782 : referencedContent.getContentIfExists(); 783 784 if (optContent.isPresent()) 785 { 786 ModifiableContent content = optContent.get(); 787 788 ModelItem modelItem = content.getDefinition(invertRelationPath); 789 ValueContext valueContext = ValueContext.newInstance(); 790 if (DataHolderHelper.getExternalizableDataProviderExtensionPoint().isDataExternalizable(content, modelItem)) 791 { 792 if (ExternalizableDataStatus.EXTERNAL.equals(content.getStatus(invertRelationPath))) 793 { 794 throw new IllegalStateException("Unable to manage the invert relation on distant content '" + content + "', the data at path '" + invertRelationPath + "' is external."); 795 } 796 else 797 { 798 valueContext.withStatus(ExternalizableDataStatus.LOCAL); 799 } 800 } 801 802 if (invertRelationManager.manageInvertRelation(content, invertRelationPath, referencedContents.get(referencedContent), valueContext)) 803 { 804 allModifiedContents.put(contentId, content); 805 } 806 } 807 } 808 809 return allModifiedContents; 810 } 811 812 /** 813 * Adds the invert relation to the given {@link DataHolder} 814 * @param dataHolder the data holder where to add the invert relation 815 * @param invertRelationPath the path of the invert relation 816 * @param referencingContentIds the id of the contents that reference the data holder 817 * @param context the value's context 818 * @return <code>true</code> if the given data holder has been modified, <code>false</code> otherwise 819 */ 820 public static boolean addInvertRelation(ModifiableModelAwareDataHolder dataHolder, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context) 821 { 822 String[] pathSegments = StringUtils.split(invertRelationPath, ModelItem.ITEM_PATH_SEPARATOR); 823 String dataName = pathSegments[0]; 824 825 if (pathSegments.length == 1) 826 { 827 ContentAttributeDefinition invertRelationDefinition = (ContentAttributeDefinition) dataHolder.getDefinition(invertRelationPath); 828 if (invertRelationDefinition.isMultiple()) 829 { 830 List<String> newValues = new ArrayList<>(); 831 832 if (DataHolderHelper.hasValue(dataHolder, invertRelationPath, context)) 833 { 834 ContentValue[] oldValues = DataHolderHelper.getValue(dataHolder, invertRelationPath, context); 835 836 newValues = Arrays.stream(oldValues) 837 .map(ContentValue::getContentId) 838 .collect(Collectors.toList()); 839 } 840 841 newValues.addAll(referencingContentIds); 842 DataHolderHelper.setValue(dataHolder, invertRelationPath, newValues.toArray(new String[newValues.size()]), context); 843 return true; 844 } 845 else 846 { 847 assert referencingContentIds.size() == 1; 848 DataHolderHelper.setValue(dataHolder, invertRelationPath, referencingContentIds.iterator().next(), context); 849 return true; 850 } 851 } 852 else 853 { 854 ModelItem modelItem = dataHolder.getDefinition(dataName); 855 String subInvertRelationPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length); 856 if (modelItem instanceof RepeaterDefinition) 857 { 858 ModifiableModelAwareRepeaterEntry repeaterEntry = dataHolder.getRepeater(dataName, true).addEntry(); 859 return addInvertRelation(repeaterEntry, subInvertRelationPath, referencingContentIds, context); 860 } 861 else 862 { 863 ModifiableModelAwareComposite composite = dataHolder.getComposite(dataName, true); 864 return addInvertRelation(composite, subInvertRelationPath, referencingContentIds, context); 865 } 866 } 867 } 868 869 /** 870 * Removes the invert relation to the given {@link DataHolder} 871 * @param referencedContent the content where to remove the invert relation 872 * @param invertRelationPath the path of the invert relation 873 * @param referencingContentIds the id of the contents that do not reference the given content anymore 874 * @param context the value's context 875 * @return <code>true</code> if the referenced content has been modified, <code>false</code> otherwise 876 */ 877 public static boolean removeInvertRelation(ModifiableContent referencedContent, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context) 878 { 879 ContentAttributeDefinition invertRelationDefinition = (ContentAttributeDefinition) referencedContent.getDefinition(invertRelationPath); 880 if (invertRelationDefinition.isMultiple()) 881 { 882 if (DataHolderHelper.hasValue(referencedContent, invertRelationPath, context)) 883 { 884 ContentValue[] oldValues = DataHolderHelper.getValue(referencedContent, invertRelationPath, context); 885 886 String[] newValues = Arrays.stream(oldValues) 887 .map(ContentValue::getContentId) 888 .filter(id -> !referencingContentIds.contains(id)) 889 .toArray(size -> new String[size]); 890 891 DataHolderHelper.setValue(referencedContent, invertRelationPath, newValues, context); 892 return oldValues.length > newValues.length; 893 } 894 else 895 { 896 return false; 897 } 898 } 899 else 900 { 901 return _removeSingleInvertRelation(referencedContent, invertRelationPath, referencingContentIds, context); 902 } 903 } 904 905 private static boolean _removeSingleInvertRelation(ModifiableModelAwareDataHolder dataHolder, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context) 906 { 907 String[] pathSegments = StringUtils.split(invertRelationPath, ModelItem.ITEM_PATH_SEPARATOR); 908 String dataName = pathSegments[0]; 909 910 if (pathSegments.length == 1) 911 { 912 ContentValue value = DataHolderHelper.getValue(dataHolder, dataName, context); 913 if (value != null && referencingContentIds.contains(value.getContentId())) 914 { 915 DataHolderHelper.removeValue(dataHolder, dataName, context); 916 return true; 917 } 918 else 919 { 920 return false; 921 } 922 } 923 else 924 { 925 ModelItem modelItem = dataHolder.getDefinition(dataName); 926 String subInvertRelationPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length); 927 if (modelItem instanceof RepeaterDefinition) 928 { 929 boolean modified = false; 930 931 ModifiableModelAwareRepeater repeater = dataHolder.getRepeater(dataName); 932 if (repeater != null) 933 { 934 for (ModifiableModelAwareRepeaterEntry repeaterEntry : repeater.getEntries()) 935 { 936 modified = _removeSingleInvertRelation(repeaterEntry, subInvertRelationPath, referencingContentIds, context) || modified; 937 } 938 } 939 940 return modified; 941 } 942 else 943 { 944 ModifiableModelAwareComposite composite = dataHolder.getComposite(dataName); 945 return _removeSingleInvertRelation(composite, subInvertRelationPath, referencingContentIds, context); 946 } 947 } 948 } 949 950 /** 951 * Invert relation manager (to add or remove a relation on a content 952 */ 953 @FunctionalInterface 954 public interface InvertRelationManager 955 { 956 /** 957 * Manages the invert relation to the given {@link DataHolder} 958 * @param referencedContent the content where to manage the invert relation 959 * @param invertRelationPath the path of the invert relation 960 * @param referencingContentIds the id of the contents that reference.d the given content 961 * @param context the value's context 962 * @return <code>true</code> if the referenced content has been modified, <code>false</code> otherwise 963 */ 964 public boolean manageInvertRelation(ModifiableContent referencedContent, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context); 965 } 966}