001/** 002 * Copyright 2005-2016 The Kuali Foundation 003 * 004 * Licensed under the Educational Community 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.opensource.org/licenses/ecl2.php 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.kuali.rice.krad.data.util; 017 018import com.google.common.collect.Sets; 019import org.apache.commons.collections.CollectionUtils; 020import org.apache.commons.lang.ArrayUtils; 021import org.apache.commons.lang.StringUtils; 022import org.apache.commons.lang3.reflect.FieldUtils; 023import org.kuali.rice.krad.data.DataObjectService; 024import org.kuali.rice.krad.data.DataObjectWrapper; 025import org.kuali.rice.krad.data.metadata.DataObjectAttributeRelationship; 026import org.kuali.rice.krad.data.metadata.DataObjectCollection; 027import org.kuali.rice.krad.data.metadata.DataObjectMetadata; 028import org.kuali.rice.krad.data.metadata.DataObjectRelationship; 029import org.kuali.rice.krad.data.metadata.MetadataChild; 030import org.slf4j.Logger; 031import org.slf4j.LoggerFactory; 032import org.springframework.beans.PropertyAccessor; 033import org.springframework.beans.PropertyAccessorUtils; 034import org.springframework.core.annotation.AnnotationUtils; 035 036import java.lang.reflect.Field; 037import java.util.ArrayList; 038import java.util.Arrays; 039import java.util.HashMap; 040import java.util.HashSet; 041import java.util.List; 042import java.util.Map; 043import java.util.Set; 044 045/** 046* The ReferenceLinker provides functionality that allows for ensuring that relationships and foreign key state are 047* populated and kept in sync as changes are made to a data object. 048* 049* <p> 050* This may include fetching relationships as keys are changed that would necessitate updating the object graph, and 051* may also ensure that foreign key values are kept in sync in situations where a data object may have more than one 052* field or object that stores the same foreign key. 053* </p> 054* 055* <p> 056* This class has a single method {@link #linkChanges(Object, java.util.Set)} which takes a data object and a list 057* of property paths for fields which have been modified. It then uses this information determine how to link up 058* relationships and foreign key fields, recursing through the object graph as needed. 059* </p> 060* 061* <p> 062* Linking occurs from the bottom up, such that this class will attempt to perform a post-order traversal to visit 063* the modified objects furthest from the root first, and then backtracking and linking back to the root. The linking 064* algorithm handles circular references as well to ensure that the linking process terminates successfully. 065* </p> 066* 067* <p> 068* The ReferenceLinker requires access to the {@link DataObjectService} so it must be injected using the 069* provided {@link #setDataObjectService(org.kuali.rice.krad.data.DataObjectService)} method. 070* </p> 071* 072* @author Kuali Rice Team (rice.collab@kuali.org) 073*/ 074public class ReferenceLinker { 075 076 private static final Logger LOG = LoggerFactory.getLogger(ReferenceLinker.class); 077 078 private DataObjectService dataObjectService; 079 080 /** 081 * Returns the DataObjectService used by this class 082 * 083 * @return the DataObjectService used by this class 084 */ 085 public DataObjectService getDataObjectService() { 086 return dataObjectService; 087 } 088 089 /** 090 * Specify the DataObjectService to be used during linking. 091 * 092 * <p> 093 * The linker will use the DataObjectService to fetch relationships and query metadata. 094 * </p> 095 * 096 * @param dataObjectService the DataObjectService to inject 097 */ 098 public void setDataObjectService(DataObjectService dataObjectService) { 099 this.dataObjectService = dataObjectService; 100 } 101 102 /** 103 * Performs linking of references and keys for the given root object based on the given set of changed property 104 * paths that should be considered during the linking process. 105 * 106 * <p> 107 * The root object should be non-null and also a valid data object (such that 108 * {@link DataObjectService#supports(Class)} returns true for it). If neither of these conditions is true, this 109 * method will return immediately. 110 * </p> 111 * <p> 112 * See class-level documentation for specifics on how the linking algorithm functions. 113 * </p> 114 * 115 * @param rootObject the root object from which to perform the linking 116 * @param changedPropertyPaths the set of property paths relative to the root object that should be considered 117 * modified by the linking algorithm 118 */ 119 public void linkChanges(Object rootObject, Set<String> changedPropertyPaths) { 120 if (rootObject == null || CollectionUtils.isEmpty(changedPropertyPaths)) { 121 return; 122 } 123 Class<?> type = rootObject.getClass(); 124 if (!dataObjectService.supports(type)) { 125 LOG.info("Object supplied for linking is not a supported data object type: " + type); 126 return; 127 } 128 if (LOG.isInfoEnabled()) { 129 LOG.info("Performing linking on instance of " + type + " with the following changed property paths: " + 130 Arrays.toString(changedPropertyPaths.toArray())); 131 } 132 Map<String, Set<String>> decomposedPaths = decomposePropertyPaths(changedPropertyPaths); 133 linkChangesInternal(rootObject, decomposedPaths, new HashSet<Object>()); 134 } 135 136 /** 137 * Internal implementation of link changes which is implemented to support recursion through the object graph. 138 * 139 * @param object the object from which to link 140 * @param changedPropertyPaths a decomposed property path map where the key of the map is a direct property path 141 * relative to the given object and the values are the remainder of the path relative to the parent path, see 142 * {@link #decomposePropertyPaths(java.util.Set)} 143 * @param linked a set containing objects which have already been linked, used to prevent infinite recursion due to 144 * circular references 145 */ 146 protected void linkChangesInternal(Object object, Map<String, Set<String>> changedPropertyPaths, 147 Set<Object> linked) { 148 if (object == null || linked.contains(object) || !dataObjectService.supports(object.getClass()) || 149 changedPropertyPaths.isEmpty()) { 150 return; 151 } 152 linked.add(object); 153 DataObjectWrapper<?> wrapped = dataObjectService.wrap(object); 154 155 // execute the linking 156 linkRelationshipChanges(wrapped, changedPropertyPaths, linked); 157 linkCollectionChanges(wrapped, changedPropertyPaths, linked); 158 cascadeLinkingAnnotations(wrapped, changedPropertyPaths, linked); 159 } 160 161 /** 162 * Link changes for relationships on the given wrapped data object. 163 * 164 * @param wrapped the wrapped data object 165 * @param decomposedPaths the decomposed map of changed property paths 166 * @param linked a set containing objects which have already been linked 167 */ 168 protected void linkRelationshipChanges(DataObjectWrapper<?> wrapped, Map<String, Set<String>> decomposedPaths, 169 Set<Object> linked) { 170 List<DataObjectRelationship> relationships = wrapped.getMetadata().getRelationships(); 171 for (DataObjectRelationship relationship : relationships) { 172 String relationshipName = relationship.getName(); 173 174 // let's get the current value and recurse down if it's a relationship that is cascaded on save 175 if (relationship.isSavedWithParent() && decomposedPaths.containsKey(relationshipName)) { 176 Object value = wrapped.getPropertyValue(relationshipName); 177 Map<String, Set<String>> nextPropertyPaths = 178 decomposePropertyPaths(decomposedPaths.get(relationshipName)); 179 linkChangesInternal(value, nextPropertyPaths, linked); 180 } 181 182 // once we have linked from the bottom up, 183 // let's check if any FK attribute modifications have occurred for this relationship 184 List<DataObjectAttributeRelationship> attributeRelationships = relationship.getAttributeRelationships(); 185 boolean modifiedAttributeRelationship = false; 186 for (DataObjectAttributeRelationship attributeRelationship : attributeRelationships) { 187 if (decomposedPaths.containsKey(attributeRelationship.getParentAttributeName())) { 188 modifiedAttributeRelationship = true; 189 break; 190 } 191 } 192 if (modifiedAttributeRelationship) { 193 // use FK attributes and nullify dangling relationship 194 wrapped.fetchRelationship(relationshipName, true, true); 195 } else if (decomposedPaths.containsKey(relationshipName)) { 196 // check if any portion of the primary key has been modified 197 Class<?> targetType = relationship.getRelatedType(); 198 DataObjectMetadata targetMetadata = 199 dataObjectService.getMetadataRepository().getMetadata(targetType); 200 Set<String> modifiedPropertyPaths = decomposedPaths.get(relationshipName); 201 if (isPrimaryKeyModified(targetMetadata, modifiedPropertyPaths)) { 202 // if the primary key is modified, fetch and replace the related object 203 // this will also copy FK values back to the parent object if it has FK values 204 wrapped.fetchRelationship(relationshipName, false, false); 205 } else { 206 // otherwise, we still want to backward copy keys on the relationship, since this relationship has 207 // been explicity included in the set of changes, we pass false for onlyLinkReadyOnly because we 208 // don't care if the FK field is read only or not, we want to copy back the value regardless 209 wrapped.linkForeignKeys(relationshipName, false); 210 } 211 } 212 } 213 } 214 215 /** 216 * Link changes for collections on the given wrapped data object. 217 * 218 * @param wrapped the wrapped data object 219 * @param decomposedPaths the decomposed map of changed property paths 220 * @param linked a set containing objects which have already been linked 221 */ 222 protected void linkCollectionChanges(DataObjectWrapper<?> wrapped, Map<String, Set<String>> decomposedPaths, 223 Set<Object> linked) { 224 List<DataObjectCollection> collections = wrapped.getMetadata().getCollections(); 225 for (DataObjectCollection collectionMetadata : collections) { 226 // We only process collections if they are being saved with the parent 227 if (collectionMetadata.isSavedWithParent()) { 228 Set<Integer> modifiedIndicies = extractModifiedIndicies(collectionMetadata, decomposedPaths); 229 if (!modifiedIndicies.isEmpty()) { 230 Object collectionValue = wrapped.getPropertyValue(collectionMetadata.getName()); 231 if (collectionValue instanceof Iterable<?>) { 232 int index = 0; 233 // loop over all elements in the collection 234 for (Object element : (Iterable<?>)collectionValue) { 235 // check if index is modified, or we use MAX_VALUE to indicate a modification to the 236 // collection itself 237 if (modifiedIndicies.contains(Integer.valueOf(Integer.MAX_VALUE)) || 238 modifiedIndicies.contains(Integer.valueOf(index))) { 239 // recurse down and link the collection element 240 String pathKey = collectionMetadata.getName() + "[" + index + "]"; 241 if (decomposedPaths.containsKey(pathKey)) { 242 Map<String, Set<String>> nextPropertyPaths = 243 decomposePropertyPaths(decomposedPaths.get(pathKey)); 244 linkChangesInternal(element, nextPropertyPaths, linked); 245 } 246 if (dataObjectService.supports(element.getClass())) { 247 DataObjectWrapper<?> elementWrapper = dataObjectService.wrap(element); 248 linkBiDirectionalCollection(wrapped, elementWrapper, collectionMetadata); 249 } 250 } 251 index++; 252 } 253 } 254 } 255 } 256 } 257 } 258 259 /** 260 * Performs bi-directional collection linking, ensuring that for bi-directional collection relationships that both 261 * sides of the relationship are properly populated. 262 * 263 * @param collectionMetadata collection 264 * @param elementWrapper element 265 * @param parentWrapper parent 266 */ 267 protected void linkBiDirectionalCollection(DataObjectWrapper<?> parentWrapper, 268 DataObjectWrapper<?> elementWrapper, DataObjectCollection collectionMetadata) { 269 MetadataChild inverseRelationship = collectionMetadata.getInverseRelationship(); 270 if (inverseRelationship != null) { 271 // if there is an inverse relationship, make sure the element is the collection is pointing back to it's parent 272 elementWrapper.setPropertyValue(inverseRelationship.getName(), parentWrapper.getWrappedInstance()); 273 // if there is a foreign key value to link, then link it, not that we pass false here, since we just set 274 // our reference to the relationship, we know that we want to copy the key back 275 elementWrapper.linkForeignKeys(inverseRelationship.getName(), false); 276 } 277 } 278 279 /** 280 * Gets indexes that have been modified. 281 * 282 * <p> 283 * Returns a set of indexes which have been modified in the given collection. If the returned set contains 284 * {@link java.lang.Integer#MAX_VALUE} then it means that it should be treated as if all items in the collection 285 * have been modified. 286 * </p> 287 * 288 * @return indexes which have been modified in the given collection 289 */ 290 private Set<Integer> extractModifiedIndicies(DataObjectCollection collectionMetadata, 291 Map<String, Set<String>> decomposedPaths) { 292 String relationshipName = collectionMetadata.getName(); 293 Set<Integer> modifiedIndicies = Sets.newHashSet(); 294 // if it contains *exactly* the collection relationship name, then indicate that all items modified 295 if (decomposedPaths.containsKey(relationshipName)) { 296 modifiedIndicies.add(Integer.valueOf(Integer.MAX_VALUE)); 297 } 298 for (String propertyName : decomposedPaths.keySet()) { 299 if (relationshipName.equals(PropertyAccessorUtils.getPropertyName(relationshipName))) { 300 Integer index = extractIndex(propertyName); 301 if (index != null) { 302 modifiedIndicies.add(index); 303 } 304 } 305 } 306 return modifiedIndicies; 307 } 308 309 /** 310 * Gets index of property name. 311 * 312 * <p> 313 * Returns the index number of the location of the given property name. 314 * </p> 315 * 316 * @param propertyName name of property to find index of. 317 * @return index number representing location of property name. 318 */ 319 private Integer extractIndex(String propertyName) { 320 int firstIndex = propertyName.indexOf(PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR); 321 int lastIndex = propertyName.lastIndexOf(PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR); 322 if (firstIndex != -1 && lastIndex != -1) { 323 String indexValue = propertyName.substring(firstIndex + 1, lastIndex); 324 try { 325 int index = Integer.parseInt(indexValue); 326 return Integer.valueOf(index); 327 } catch (NumberFormatException e) { 328 // if we encounter this then it wasn't really an index, ignore 329 } 330 } 331 return null; 332 } 333 334 /** 335 * Checks if primary key can be modified. 336 * 337 * @return whether primary key can be modified. 338 */ 339 protected boolean isPrimaryKeyModified(DataObjectMetadata metadata, Set<String> modifiedPropertyPaths) { 340 Set<String> primaryKeyAttributeNames = new HashSet<String>(metadata.getPrimaryKeyAttributeNames()); 341 for (String modifiedPropertyPath : modifiedPropertyPaths) { 342 if (primaryKeyAttributeNames.contains(modifiedPropertyPath)) { 343 return true; 344 } 345 } 346 return false; 347 } 348 349 /** 350 * Gets indexes that have been modified. 351 * 352 * <p> 353 * Returns a list of cascade links in the field names that are also in the decomposed paths. 354 * </p> 355 * 356 * @param decomposedPaths contains field names to be used. 357 * @param linked 358 * @param wrapped used to get all field names. 359 */ 360 protected void cascadeLinkingAnnotations(DataObjectWrapper<?> wrapped, Map<String, Set<String>> decomposedPaths, 361 Set<Object> linked) { 362 Field[] fields = FieldUtils.getAllFields(wrapped.getWrappedClass()); 363 Map<String, Field> modifiedFieldMap = new HashMap<String, Field>(); 364 for (Field field : fields) { 365 if (decomposedPaths.containsKey(field.getName())) { 366 modifiedFieldMap.put(field.getName(), field); 367 } 368 } 369 for (String modifiedFieldName : modifiedFieldMap.keySet()) { 370 Field modifiedField = modifiedFieldMap.get(modifiedFieldName); 371 Link link = modifiedField.getAnnotation(Link.class); 372 if (link == null) { 373 // check if they have an @Link on the class itself 374 link = AnnotationUtils.findAnnotation(modifiedField.getType(), Link.class); 375 } 376 if (link != null && link.cascade()) { 377 List<String> linkingPaths = assembleLinkingPaths(link); 378 for (String linkingPath : linkingPaths) { 379 Map<String, Set<String>> decomposedLinkingPath = 380 decomposePropertyPaths(decomposedPaths.get(modifiedFieldName), linkingPath); 381 String valuePath = modifiedFieldName; 382 if (StringUtils.isNotBlank(linkingPath)) { 383 valuePath = valuePath + "." + link.path(); 384 } 385 Object linkRootObject = wrapped.getPropertyValueNullSafe(valuePath); 386 linkChangesInternal(linkRootObject, decomposedLinkingPath, linked); 387 } 388 } 389 } 390 } 391 392 /** 393 * Returns a list of link paths based on provided link. 394 * 395 * @param link used get paths from. 396 * @return list of paths 397 */ 398 protected List<String> assembleLinkingPaths(Link link) { 399 List<String> linkingPaths = new ArrayList<String>(); 400 if (ArrayUtils.isEmpty(link.path())) { 401 linkingPaths.add(""); 402 } else { 403 for (String path : link.path()) { 404 linkingPaths.add(path); 405 } 406 } 407 return linkingPaths; 408 } 409 410 /** 411 * Returns decomposed property paths based on changedPropertyPaths 412 * 413 * @param changedPropertyPaths changes to property paths 414 * @return map decomposed property paths with changedPropertyPaths 415 */ 416 protected Map<String, Set<String>> decomposePropertyPaths(Set<String> changedPropertyPaths) { 417 return decomposePropertyPaths(changedPropertyPaths, ""); 418 } 419 420 /** 421 * Returns decomposed property paths that start with the provide prefix 422 * 423 * @param changedPropertyPaths changes to property paths 424 * @param prefix filter that paths must start with 425 * @return map decomposed property paths with changedPropertyPaths 426 */ 427 protected Map<String, Set<String>> decomposePropertyPaths(Set<String> changedPropertyPaths, String prefix) { 428 // strip the prefix off any changed properties 429 Set<String> processedProperties = new HashSet<String>(); 430 if (StringUtils.isNotBlank(prefix) && changedPropertyPaths != null) { 431 for (String changedPropertyPath : changedPropertyPaths) { 432 if (changedPropertyPath.startsWith(prefix)) { 433 processedProperties.add(changedPropertyPath.substring(prefix.length() + 1)); 434 } 435 } 436 } else { 437 processedProperties = changedPropertyPaths; 438 } 439 Map<String, Set<String>> decomposedPropertyPaths = new HashMap<String, Set<String>>(); 440 for (String changedPropertyPath : processedProperties) { 441 int index = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(changedPropertyPath); 442 if (index == -1) { 443 decomposedPropertyPaths.put(changedPropertyPath, new HashSet<String>()); 444 } else { 445 String pathEntry = changedPropertyPath.substring(0, index); 446 Set<String> remainingPaths = decomposedPropertyPaths.get(pathEntry); 447 if (remainingPaths == null) { 448 remainingPaths = new HashSet<String>(); 449 decomposedPropertyPaths.put(pathEntry, remainingPaths); 450 } 451 String remainingPath = changedPropertyPath.substring(index + 1); 452 remainingPaths.add(remainingPath); 453 } 454 } 455 return decomposedPropertyPaths; 456 } 457 458}