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}