001package com.identityworksllc.iiq.common;
002
003import org.apache.commons.logging.Log;
004import org.apache.commons.logging.LogFactory;
005import sailpoint.object.Attributes;
006import sailpoint.tools.Util;
007
008import java.util.*;
009
010/**
011 * A utility for merging multiple Map objects into a single Attributes-equivalent
012 * object. This can be used to stack configurations, e.g., overlaying an Application
013 * level configuration onto a global system configuration.
014 * 
015 * The utility offers two "modes": overlay and merge. An overlay will simply overwrite
016 * values from the later inputs with values from the earlier inputs. Merge is more subtle
017 * and will combine values as needed.
018 * 
019 * To overlay maps, invoke {@link #overlayConfigurations(boolean, Map...)}.
020 * 
021 * To merge maps, invoke {@link #mergeConfigurations(Map...)}.
022 */
023public class ConfigurationMerger {
024
025    /**
026     * The token used to indicate that a value should be removed from the
027     * result, rather than merged.
028     */
029        public static final String NULL_TOKEN = "(null)";
030
031    /**
032     * The token used to replace a value in the target Map
033     */
034    public static final String REPLACE_TOKEN = "_replace";
035        /**
036         * Logger used for tracing purposes
037         */
038        private static final Log log = LogFactory.getLog(ConfigurationMerger.class);
039        
040        /**
041         * Merges a series of Maps as non-destructively as possible.
042         * 
043         * See {@link #mergeMaps(Map, Map)} for details on how the merge process works.
044         * 
045         * @param inputs The list of Map sources
046         * @return A new Attributes object containing an overlay of each Map
047         */
048        @SafeVarargs
049        public static Attributes<String, Object> mergeConfigurations(Map<String, Object>... inputs) {
050                Attributes<String, Object> output = new Attributes<>();
051                for(Map<String, Object> input : inputs) {
052                        if (input != null) {
053                                output = new Attributes<String, Object>(mergeMaps(input, output));
054                                
055                                if (log.isTraceEnabled()) {
056                                        log.trace("Merged configuration after round is " + output);
057                                }
058                        }
059                }
060                return output;
061        }
062        
063        
064        /**
065         * Merges two values, one of which must be a Collection. The result will be
066         * a merged view of the two values. Any input that is not already a Collection
067         * will be passed through {@link #toList(Object)} for conversion.
068         * 
069         * The output will always be a new non-null List. The existing values will be 
070         * added first, followed by any new values that are not already in the existing
071         * values. Duplicate values will not be added.
072         * 
073         * @param newValues The new value(s) to add to the merged list
074         * @param existingValue The existing value(s) already in the list
075         * @return A new List containing the two values merged
076         */
077        public static List<Object> mergeLists(final Object newValues, final Object existingValue) {
078                if (!(newValues instanceof Collection || existingValue instanceof Collection)) {
079                        throw new IllegalArgumentException("At least one argument to mergeLists must contain at least one List");
080                }
081                
082                List<Object> output = new ArrayList<Object>();
083                
084                List<?> l1 = toList(newValues);
085                List<?> l2 = toList(existingValue);
086                
087                output.addAll(l2);
088                
089                for(Object o : l1) {
090                        if (!output.contains(o)) {
091                                output.add(o);
092                        }
093                }
094                
095                return output;
096        }
097        
098        /**
099         * Merges the 'new' Map into the 'existing' Map, returning a new Map is that is a merger
100         * of the two. Neither parameter Map will be modified. 
101         * 
102         * If both inputs are null, the output will be empty.
103         * 
104         * If only the existing Map is null, the output will contain only the 'new' values.
105         * 
106         * If only the new Map is null, the output will contain only the 'existing' values.
107         * 
108         * If both Maps are non-null, the following procedure will be applied.
109         * 
110         * If the new Map contains the special key '_replace', it is expected to 
111         * contain a list of field names. The values for those specific keys will
112         * be replaced rather than merged in the output, even if a merge would 
113         * normally be possible.
114         * 
115         * For each key:
116         * 
117         *  - If the new value is null, it will be ignored quietly.
118         *  - If the new value is the string '(null)', with the parens, the corresponding key will be removed from the result.
119         *  - If the existing Map does not already contain that key, the value is inserted as-is.
120         *  - If both new and existing values are Maps, they will be merged via a recursive call to this method
121         *  - If either new or existing values are Collections, they will be merged via {@link #mergeLists(Object, Object)}
122         *  - Otherwise, the new value will overwrite the old value
123         * 
124         * @param newMap The new map to merge into the final product
125         * @param existingMap The existing map, built from this or previous mergers
126     * @return The merged Map
127         */
128        @SuppressWarnings("unchecked")
129        public static Map<String, Object> mergeMaps(final Map<String, Object> newMap, final Map<String, Object> existingMap) {
130                if (existingMap == null) {
131                        if (newMap != null) {
132                                return new HashMap<>(newMap);
133                        } else {
134                                return new HashMap<>();
135                        }
136                }
137                Set<String> skipKeys = new HashSet<>();
138                if (newMap != null && newMap.containsKey(REPLACE_TOKEN)) {
139                        skipKeys = new HashSet<>(Util.otol(newMap.get(REPLACE_TOKEN)));
140                }
141                Map<String, Object> target = new HashMap<String, Object>(existingMap);
142                skipKeys.forEach(target::remove);
143                if (newMap != null) {
144                        for(String key : newMap.keySet()) {
145                                final Object newValue = newMap.get(key);
146                                final Object existingValue = target.get(key);
147                                
148                                if (log.isDebugEnabled()) {
149                                        log.debug("New value for key " + key + " = " + newValue);
150                                }
151                                
152                                if (newValue != null) {
153                                        if (newValue instanceof String && (newValue.equals("") || newValue.equals(NULL_TOKEN))) {
154                                                target.remove(key);
155                                        } else if (existingValue == null) {
156                                                // If the existing value is null, we just insert the new one
157                                                target.put(key, newValue);
158                                        } else if (newValue instanceof Map || existingValue instanceof Map) {
159                                                // Maps require a nested merge operation
160                                                if (existingValue instanceof Map) {
161                                                        Map<String, Object> newTarget = new HashMap<String, Object>();
162                            newTarget.putAll((Map<String, Object>) existingValue);
163                            newTarget = mergeMaps((Map<String, Object>)newValue, newTarget);
164                                                        target.put(key, newTarget);
165                                                } else {
166                                                        target.put(key, newValue);
167                                                }
168                                        } else if (newValue instanceof Collection || existingValue instanceof Collection) {
169                                                List<Object> newList = mergeLists(newValue, existingValue);
170                                                target.put(key, newList);
171                                        } else {
172                                                // New overrides old
173                                                target.put(key, newValue);
174                                        }
175                                }
176                        }
177                }
178                return target;
179        }
180        
181        /**
182         * Overlays a series of Maps in a destructive way. 
183         * 
184         * For each key in each Map, the value in later Maps overlays the value in earlier maps.
185         * 
186         * If the input is an empty string or the special value '(null)', and ignoreNulls is false, 
187         * the key will be removed from the resulting Map unless a later value in the series of Maps 
188         * inserts a new one. If ignoreNulls is true, these values will be ignored as though they were
189         * a null input.
190         * 
191         * If 'ignoreNulls' is true, null inputs will just be ignored. 
192         * 
193         * Unlike {@link ConfigurationMerger#mergeConfigurations(Map...)}, collection data is not merged. 
194         * Lists in later maps will obliterate the values in earlier maps.
195         * 
196         * @param ignoreNulls If true, null values in any map will simply be ignored, rather than overwriting
197         * @param sources The list of Map sources
198         * @return A new Attributes object containing an overlay of each Map
199         */
200        @SafeVarargs
201        public static Attributes<String, Object> overlayConfigurations(boolean ignoreNulls, Map<String, Object>... sources) {
202                Attributes<String, Object> result = new Attributes<>();
203                
204                for(Map<String, Object> source : sources) {
205                        if (source != null) {
206                                for(final String key : source.keySet()) {
207                                        Object value = source.get(key);
208                                        
209                                        if (value instanceof String && (value.equals("") || value.equals(NULL_TOKEN))) {
210                                                value = null;
211                                        }
212                                        
213                                        if (value == null && ignoreNulls) {
214                                                continue;
215                                        }
216                                        
217                                        if (value == null) {
218                                                result.remove(key);
219                                        } else {
220                                                result.put(key, value);
221
222                                        }
223                                }
224                        }
225                        
226                        if (log.isTraceEnabled()) {
227                                log.trace("Overlaid configuration after round is " + result);
228                        }
229
230                }
231                
232                return result;
233        }
234        
235        /**
236         * Converts the input object to a non-null List. If the input is a List, its
237         * values will be added to the result as-is. If the input is a String, it will
238         * be passed through Sailpoint's CSV parser. If the input is anything else, the
239         * list will contain only that item.
240         * 
241         * If the input is null, the output list will be empty.
242         * 
243         * @param value The input object to convert to a list
244         * @return A non-null list containing the converted input
245         */
246        @SuppressWarnings("unchecked")
247        private static List<Object> toList(final Object value) {
248                List<Object> output = new ArrayList<Object>();
249                if (value instanceof Collection) {
250                        output.addAll((Collection<? extends Object>) value);
251                } else if (value instanceof String) {
252                        List<String> listified = Util.stringToList((String)value);
253                        if (listified != null) {
254                                output.addAll(listified);
255                        }
256                } else if (value != null) {
257                        output.add(value);
258                }
259                return output;
260        }
261
262        /**
263         * This class should never be constructed
264         */
265        private ConfigurationMerger() {
266                throw new UnsupportedOperationException();
267        }
268}