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}