View Javadoc
1   /**
2    * Copyright 2005-2015 The Kuali Foundation
3    *
4    * Licensed under the Educational Community License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    * http://www.opensource.org/licenses/ecl2.php
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package org.kuali.rice.krad.data.util;
17  
18  import com.google.common.collect.Sets;
19  import org.apache.commons.collections.CollectionUtils;
20  import org.apache.commons.lang.ArrayUtils;
21  import org.apache.commons.lang.StringUtils;
22  import org.apache.commons.lang3.reflect.FieldUtils;
23  import org.kuali.rice.krad.data.DataObjectService;
24  import org.kuali.rice.krad.data.DataObjectWrapper;
25  import org.kuali.rice.krad.data.metadata.DataObjectAttributeRelationship;
26  import org.kuali.rice.krad.data.metadata.DataObjectCollection;
27  import org.kuali.rice.krad.data.metadata.DataObjectMetadata;
28  import org.kuali.rice.krad.data.metadata.DataObjectRelationship;
29  import org.kuali.rice.krad.data.metadata.MetadataChild;
30  import org.slf4j.Logger;
31  import org.slf4j.LoggerFactory;
32  import org.springframework.beans.PropertyAccessor;
33  import org.springframework.beans.PropertyAccessorUtils;
34  import org.springframework.core.annotation.AnnotationUtils;
35  
36  import java.lang.reflect.Field;
37  import java.util.ArrayList;
38  import java.util.Arrays;
39  import java.util.HashMap;
40  import java.util.HashSet;
41  import java.util.List;
42  import java.util.Map;
43  import java.util.Set;
44  
45  /**
46  * The ReferenceLinker provides functionality that allows for ensuring that relationships and foreign key state are
47  * populated and kept in sync as changes are made to a data object.
48  *
49  * <p>
50  *     This may include fetching relationships as keys are changed that would necessitate updating the object graph, and
51  *     may also ensure that foreign key values are kept in sync in situations where a data object may have more than one
52  *     field or object that stores the same foreign key.
53  * </p>
54  *
55  * <p>
56  *     This class has a single method {@link #linkChanges(Object, java.util.Set)} which takes a data object and a list
57  *     of property paths for fields which have been modified. It then uses this information determine how to link up
58  *     relationships and foreign key fields, recursing through the object graph as needed.
59  * </p>
60  *
61  * <p>
62  *     Linking occurs from the bottom up, such that this class will attempt to perform a post-order traversal to visit
63  *     the modified objects furthest from the root first, and then backtracking and linking back to the root. The linking
64  *     algorithm handles circular references as well to ensure that the linking process terminates successfully.
65  * </p>
66  *
67  * <p>
68  *     The ReferenceLinker requires access to the {@link DataObjectService} so it must be injected using the
69  *     provided {@link #setDataObjectService(org.kuali.rice.krad.data.DataObjectService)} method.
70  * </p>
71  *
72  * @author Kuali Rice Team (rice.collab@kuali.org)
73  */
74  public class ReferenceLinker {
75  
76      private static final Logger LOG = LoggerFactory.getLogger(ReferenceLinker.class);
77  
78      private DataObjectService dataObjectService;
79  
80      /**
81      * Returns the DataObjectService used by this class
82      *
83      * @return the DataObjectService used by this class
84      */
85      public DataObjectService getDataObjectService() {
86          return dataObjectService;
87      }
88  
89      /**
90      * Specify the DataObjectService to be used during linking.
91      *
92      * <p>
93      *     The linker will use the DataObjectService to fetch relationships and query metadata.
94      * </p>
95      *
96      * @param dataObjectService the DataObjectService to inject
97      */
98      public void setDataObjectService(DataObjectService dataObjectService) {
99          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 }